switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.

This commit is contained in:
Codex Agent
2025-10-27 17:26:39 +01:00
parent ecf5a23b28
commit 5432456ffd
117 changed files with 4114 additions and 3639 deletions

View File

@@ -84,6 +84,20 @@ REVENUECAT_WEBHOOK_QUEUE=webhooks
CHECKOUT_WIZARD_ENABLED=true CHECKOUT_WIZARD_ENABLED=true
CHECKOUT_WIZARD_FLAG=checkout-wizard-2025 CHECKOUT_WIZARD_FLAG=checkout-wizard-2025
# PayPal
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_SANDBOX=true
# Paddle Billing
PADDLE_SANDBOX=true
PADDLE_API_KEY=
PADDLE_CLIENT_ID=
PADDLE_WEBHOOK_SECRET=
PADDLE_PUBLIC_KEY=
PADDLE_BASE_URL=
PADDLE_CONSOLE_URL=
OAUTH_JWT_KID=fotospiel-jwt OAUTH_JWT_KID=fotospiel-jwt
OAUTH_KEY_STORE= OAUTH_KEY_STORE=
OAUTH_REFRESH_ENFORCE_IP=true OAUTH_REFRESH_ENFORCE_IP=true

View File

@@ -28,8 +28,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev). - Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev).
- Git Hosting: Gogs at http://nas:10880 (token found locally in gogs.ini, never printed or committed). - Git Hosting: Gogs at http://nas:10880 (token found locally in gogs.ini, never printed or committed).
- Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth). - Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth).
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Stripe PHP SDK for payments; PayPal Server SDK for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n. - Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n.
- Payment Systems: Stripe (subscriptions and one-time payments), PayPal (integrated payments), RevenueCat (mobile app subscriptions). - Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions).
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync. - PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
## Repo Structure (high-level) ## Repo Structure (high-level)
@@ -59,7 +59,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- tenant:add-dummy — create a demo tenant and admin user (see --help for options). - tenant:add-dummy — create a demo tenant and admin user (see --help for options).
- tenant:attach-demo-event — attach an existing demo event to a tenant. - tenant:attach-demo-event — attach an existing demo event to a tenant.
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md. - Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md.
- Payment Integration: Stripe webhooks, PayPal API integration, RevenueCat mobile subscriptions. - Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
## PWA Architecture ## PWA Architecture
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required). - Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands;
use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle;
use App\Models\Package;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class PaddleSyncPackages extends Command
{
protected $signature = 'paddle:sync-packages
{--package=* : Limit sync to the given package IDs or slugs}
{--dry-run : Generate payload snapshots without calling Paddle}
{--pull : Fetch remote Paddle state instead of pushing local changes}
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
protected $description = 'Synchronise local packages with Paddle products and prices.';
public function handle(): int
{
$packages = $this->resolvePackages();
if ($packages->isEmpty()) {
$this->warn('No packages matched the given filters.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$pull = (bool) $this->option('pull');
$queue = (bool) $this->option('queue');
$packages->each(function (Package $package) use ($dryRun, $pull, $queue) {
if ($pull) {
$this->dispatchPullJob($package, $queue);
return;
}
$this->dispatchSyncJob($package, $dryRun, $queue);
});
$this->info(sprintf(
'Queued %d package %s for Paddle %s.',
$packages->count(),
Str::plural('entry', $packages->count()),
$pull ? 'pull' : 'sync'
));
return self::SUCCESS;
}
protected function resolvePackages(): Collection
{
$keys = collect((array) $this->option('package'))->filter();
$query = Package::query();
if ($keys->isNotEmpty()) {
$ids = $keys
->filter(fn ($value) => is_numeric($value))
->map(fn ($value) => (int) $value);
$slugs = $keys
->reject(fn ($value) => is_numeric($value))
->values();
$query->where(function ($builder) use ($ids, $slugs) {
if ($ids->isNotEmpty()) {
$builder->orWhereIn('id', $ids);
}
if ($slugs->isNotEmpty()) {
$builder->orWhereIn('slug', $slugs);
}
});
}
return $query->orderByDesc('id')->get();
}
protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void
{
$context = [
'dry_run' => $dryRun,
];
if ($queue) {
SyncPackageToPaddle::dispatch($package->id, $context);
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
return;
}
SyncPackageToPaddle::dispatchSync($package->id, $context);
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
}
protected function dispatchPullJob(Package $package, bool $queue): void
{
if ($queue) {
PullPackageFromPaddle::dispatch($package->id);
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
return;
}
PullPackageFromPaddle::dispatchSync($package->id);
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
}
}

View File

@@ -3,7 +3,11 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\PackageResource\Pages; use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle;
use App\Models\Package; use App\Models\Package;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
@@ -11,21 +15,23 @@ use Filament\Actions\EditAction;
use Filament\Actions\ViewAction; use Filament\Actions\ViewAction;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\MarkdownEditor; use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Repeater;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
use BackedEnum;
class PackageResource extends Resource class PackageResource extends Resource
{ {
@@ -150,6 +156,28 @@ class PackageResource extends Resource
->columnSpanFull() ->columnSpanFull()
->default([]), ->default([]),
]), ]),
Section::make('Paddle Billing')
->columns(2)
->schema([
TextInput::make('paddle_product_id')
->label('Paddle Produkt-ID')
->maxLength(191)
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
->placeholder('nicht verknüpft'),
TextInput::make('paddle_price_id')
->label('Paddle Preis-ID')
->maxLength(191)
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
->placeholder('nicht verknüpft'),
Placeholder::make('paddle_sync_status')
->label('Sync-Status')
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '')
->columnSpanFull(),
Placeholder::make('paddle_synced_at')
->label('Zuletzt synchronisiert')
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '')
->columnSpanFull(),
]),
]); ]);
} }
@@ -214,6 +242,28 @@ class PackageResource extends Resource
->label('Features') ->label('Features')
->wrap() ->wrap()
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)), ->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
TextColumn::make('paddle_product_id')
->label('Paddle Produkt')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
TextColumn::make('paddle_price_id')
->label('Paddle Preis')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
BadgeColumn::make('paddle_sync_status')
->label('Sync-Status')
->colors([
'success' => 'synced',
'warning' => 'syncing',
'info' => 'dry-run',
'danger' => ['failed', 'pull-failed'],
])
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_synced_at')
->label('Sync am')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('type') Tables\Filters\SelectFilter::make('type')
@@ -224,6 +274,35 @@ class PackageResource extends Resource
]), ]),
]) ])
->actions([ ->actions([
Actions\Action::make('syncPaddle')
->label('Mit Paddle abgleichen')
->icon('heroicon-o-cloud-arrow-up')
->color('success')
->requiresConfirmation()
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing')
->action(function (Package $record) {
SyncPackageToPaddle::dispatch($record->id);
Notification::make()
->success()
->title('Paddle-Sync gestartet')
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
->send();
}),
Actions\Action::make('pullPaddle')
->label('Status von Paddle holen')
->icon('heroicon-o-cloud-arrow-down')
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id)
->requiresConfirmation()
->action(function (Package $record) {
PullPackageFromPaddle::dispatch($record->id);
Notification::make()
->info()
->title('Paddle-Abgleich angefordert')
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.')
->send();
}),
ViewAction::make(), ViewAction::make(),
EditAction::make(), EditAction::make(),
DeleteAction::make(), DeleteAction::make(),

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Resources;
use App\Filament\Resources\PurchaseResource\Pages; use App\Filament\Resources\PurchaseResource\Pages;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
@@ -11,23 +12,19 @@ use Filament\Actions\EditAction;
use Filament\Actions\ViewAction; use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use BackedEnum;
use UnitEnum;
class PurchaseResource extends Resource class PurchaseResource extends Resource
{ {
protected static ?string $model = PackagePurchase::class; protected static ?string $model = PackagePurchase::class;
@@ -167,7 +164,7 @@ class PurchaseResource extends Resource
->visible(fn (PackagePurchase $record): bool => ! $record->refunded) ->visible(fn (PackagePurchase $record): bool => ! $record->refunded)
->action(function (PackagePurchase $record) { ->action(function (PackagePurchase $record) {
$record->update(['refunded' => true]); $record->update(['refunded' => true]);
// TODO: Call Stripe/PayPal API for actual refund // TODO: Call Stripe/Paddle API for actual refund
Log::info('Refund processed for purchase ID: '.$record->id); Log::info('Refund processed for purchase ID: '.$record->id);
}), }),
]) ])

View File

@@ -23,7 +23,7 @@ class ViewPurchase extends ViewRecord
->visible(fn ($record): bool => ! $record->refunded) ->visible(fn ($record): bool => ! $record->refunded)
->action(function ($record) { ->action(function ($record) {
$record->update(['refunded' => true]); $record->update(['refunded' => true]);
// TODO: Call Stripe/PayPal API for actual refund // TODO: Call Stripe/Paddle API for actual refund
}), }),
]; ];
} }

View File

@@ -3,38 +3,37 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Models\Tenant;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\DateTimePicker;
use Filament\Tables\Columns\IconColumn;
use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager; use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager;
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager; use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
use Filament\Resources\RelationManagers\RelationGroup; use App\Models\Tenant;
use Filament\Notifications\Notification;
use UnitEnum;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use UnitEnum;
class TenantResource extends Resource class TenantResource extends Resource
{ {
protected static ?string $model = Tenant::class; protected static ?string $model = Tenant::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-building-office'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-building-office';
protected static UnitEnum|string|null $navigationGroup = null; protected static UnitEnum|string|null $navigationGroup = null;
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.platform_management'); return __('admin.nav.platform_management');
} }
protected static ?int $navigationSort = 10; protected static ?int $navigationSort = 10;
public static function form(Schema $form): Schema public static function form(Schema $form): Schema
@@ -61,6 +60,11 @@ class TenantResource extends Resource
->label(__('admin.tenants.fields.event_credits_balance')) ->label(__('admin.tenants.fields.event_credits_balance'))
->numeric() ->numeric()
->readOnly(), ->readOnly(),
TextInput::make('paddle_customer_id')
->label('Paddle Customer ID')
->maxLength(191)
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
->nullable(),
TextInput::make('total_revenue') TextInput::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue')) ->label(__('admin.tenants.fields.total_revenue'))
->prefix('€') ->prefix('€')
@@ -104,6 +108,10 @@ class TenantResource extends Resource
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'), ->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'), Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('paddle_customer_id')
->label('Paddle Customer')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
Tables\Columns\TextColumn::make('event_credits_balance') Tables\Columns\TextColumn::make('event_credits_balance')
->label(__('admin.tenants.fields.event_credits_balance')) ->label(__('admin.tenants.fields.event_credits_balance'))
->badge() ->badge()
@@ -162,6 +170,7 @@ class TenantResource extends Resource
\App\Models\PackagePurchase::create([ \App\Models\PackagePurchase::create([
'tenant_id' => $record->id, 'tenant_id' => $record->id,
'package_id' => $data['package_id'], 'package_id' => $data['package_id'],
'provider' => 'manual',
'provider_id' => 'manual', 'provider_id' => 'manual',
'type' => 'reseller_subscription', 'type' => 'reseller_subscription',
'price' => 0, 'price' => 0,

View File

@@ -2,23 +2,19 @@
namespace App\Filament\Resources\TenantResource\RelationManagers; namespace App\Filament\Resources\TenantResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ViewAction; use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class PackagePurchasesRelationManager extends RelationManager class PackagePurchasesRelationManager extends RelationManager
{ {
@@ -43,24 +39,23 @@ class PackagePurchasesRelationManager extends RelationManager
'reseller_subscription' => 'Reseller-Abo', 'reseller_subscription' => 'Reseller-Abo',
]) ])
->required(), ->required(),
TextInput::make('purchased_price') Select::make('provider')
->label('Gekaufter Preis')
->numeric()
->step(0.01)
->prefix('€')
->required(),
Select::make('provider_id')
->label('Anbieter') ->label('Anbieter')
->options([ ->options([
'paddle' => 'Paddle',
'stripe' => 'Stripe', 'stripe' => 'Stripe',
'paypal' => 'PayPal',
'manual' => 'Manuell', 'manual' => 'Manuell',
'free' => 'Kostenlos', 'free' => 'Kostenlos',
]) ])
->required(), ->required(),
TextInput::make('transaction_id') TextInput::make('provider_id')
->label('Transaktions-ID') ->label('Provider-Referenz')
->maxLength(255), ->maxLength(255),
TextInput::make('price')
->label('Preis')
->numeric()
->step(0.01)
->prefix('€'),
Toggle::make('refunded') Toggle::make('refunded')
->label('Rückerstattet'), ->label('Rückerstattet'),
Textarea::make('metadata') Textarea::make('metadata')
@@ -90,15 +85,17 @@ class PackagePurchasesRelationManager extends RelationManager
TextColumn::make('price') TextColumn::make('price')
->money('EUR') ->money('EUR')
->sortable(), ->sortable(),
TextColumn::make('provider_id') TextColumn::make('provider')
->badge() ->badge()
->color(fn (string $state): string => match ($state) { ->color(fn (string $state): string => match ($state) {
'paddle' => 'success',
'stripe' => 'info', 'stripe' => 'info',
'paypal' => 'warning',
'manual' => 'gray', 'manual' => 'gray',
'free' => 'success', 'free' => 'success',
default => 'gray',
}), }),
TextColumn::make('transaction_id') TextColumn::make('provider_id')
->label('Provider-Referenz')
->copyable() ->copyable()
->toggleable(), ->toggleable(),
TextColumn::make('metadata') TextColumn::make('metadata')
@@ -117,10 +114,10 @@ class PackagePurchasesRelationManager extends RelationManager
'endcustomer_event' => 'Endkunden-Event', 'endcustomer_event' => 'Endkunden-Event',
'reseller_subscription' => 'Reseller-Abo', 'reseller_subscription' => 'Reseller-Abo',
]), ]),
SelectFilter::make('provider_id') SelectFilter::make('provider')
->options([ ->options([
'paddle' => 'Paddle',
'stripe' => 'Stripe', 'stripe' => 'Stripe',
'paypal' => 'PayPal',
'manual' => 'Manuell', 'manual' => 'Manuell',
'free' => 'Kostenlos', 'free' => 'Kostenlos',
]), ]),
@@ -141,4 +138,3 @@ class PackagePurchasesRelationManager extends RelationManager
]); ]);
} }
} }

View File

@@ -2,24 +2,21 @@
namespace App\Filament\Resources\TenantResource\RelationManagers; namespace App\Filament\Resources\TenantResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class TenantPackagesRelationManager extends RelationManager class TenantPackagesRelationManager extends RelationManager
{ {
@@ -40,6 +37,11 @@ class TenantPackagesRelationManager extends RelationManager
DateTimePicker::make('expires_at') DateTimePicker::make('expires_at')
->label('Ablaufdatum') ->label('Ablaufdatum')
->required(), ->required(),
TextInput::make('paddle_subscription_id')
->label('Paddle Subscription ID')
->maxLength(191)
->helperText('Abonnement-ID aus Paddle Billing.')
->nullable(),
Toggle::make('active') Toggle::make('active')
->label('Aktiv'), ->label('Aktiv'),
Textarea::make('reason') Textarea::make('reason')
@@ -70,6 +72,10 @@ class TenantPackagesRelationManager extends RelationManager
TextColumn::make('expires_at') TextColumn::make('expires_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
TextColumn::make('paddle_subscription_id')
->label('Paddle Subscription')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
IconColumn::make('active') IconColumn::make('active')
->boolean() ->boolean()
->color(fn (bool $state): string => $state ? 'success' : 'danger'), ->color(fn (bool $state): string => $state ? 'success' : 'danger'),

View File

@@ -5,21 +5,17 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use PayPal\Checkout\Orders\OrdersCaptureRequest;
use PayPal\Checkout\Orders\OrdersCreateRequest;
use PayPal\Environment\LiveEnvironment;
use PayPal\Environment\SandboxEnvironment;
use PayPal\PayPalClient;
class PackageController extends Controller class PackageController extends Controller
{ {
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$type = $request->query('type', 'endcustomer'); $type = $request->query('type', 'endcustomer');
@@ -51,7 +47,7 @@ class PackageController extends Controller
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
'type' => 'required|in:endcustomer,reseller', 'type' => 'required|in:endcustomer,reseller',
'payment_method' => 'required|in:stripe,paypal', 'payment_method' => 'required|in:stripe,paddle',
'event_id' => 'nullable|exists:events,id', // For endcustomer 'event_id' => 'nullable|exists:events,id', // For endcustomer
]); ]);
@@ -105,8 +101,8 @@ class PackageController extends Controller
{ {
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
'payment_method_id' => 'required_without:paypal_order_id|string', 'payment_method_id' => 'required_without:paddle_transaction_id|string',
'paypal_order_id' => 'required_without:payment_method_id|string', 'paddle_transaction_id' => 'required_without:payment_method_id|string',
]); ]);
$package = Package::findOrFail($request->package_id); $package = Package::findOrFail($request->package_id);
@@ -116,13 +112,14 @@ class PackageController extends Controller
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
} }
$provider = $request->has('paypal_order_id') ? 'paypal' : 'stripe'; $provider = $request->has('paddle_transaction_id') ? 'paddle' : 'stripe';
DB::transaction(function () use ($request, $package, $tenant, $provider) { DB::transaction(function () use ($request, $package, $tenant, $provider) {
PackagePurchase::create([ PackagePurchase::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
'provider_id' => $request->input($provider === 'paypal' ? 'paypal_order_id' : 'payment_method_id'), 'provider' => $provider,
'provider_id' => $request->input($provider === 'paddle' ? 'paddle_transaction_id' : 'payment_method_id'),
'price' => $package->price, 'price' => $package->price,
'type' => 'endcustomer_event', 'type' => 'endcustomer_event',
'purchased_at' => now(), 'purchased_at' => now(),
@@ -165,6 +162,7 @@ class PackageController extends Controller
PackagePurchase::create([ PackagePurchase::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
'provider' => 'free',
'provider_id' => 'free_wizard', 'provider_id' => 'free_wizard',
'price' => $package->price, 'price' => $package->price,
'type' => 'endcustomer_event', 'type' => 'endcustomer_event',
@@ -186,156 +184,33 @@ class PackageController extends Controller
], 201); ], 201);
} }
public function createPayPalOrder(Request $request): JsonResponse public function createPaddleCheckout(Request $request): JsonResponse
{ {
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
'success_url' => 'nullable|url',
'return_url' => 'nullable|url',
]); ]);
$package = Package::findOrFail($request->package_id); $package = Package::findOrFail($request->integer('package_id'));
$tenant = $request->attributes->get('tenant'); $tenant = $request->attributes->get('tenant');
if (! $tenant) { if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
} }
$environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment( if (! $package->paddle_price_id) {
config('services.paypal.client_id'), throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
config('services.paypal.secret') }
) : new LiveEnvironment(
config('services.paypal.client_id'),
config('services.paypal.secret')
);
$client = PayPalClient::client($environment); $payload = [
'success_url' => $request->input('success_url'),
$request = new OrdersCreateRequest; 'return_url' => $request->input('return_url'),
$request->prefer('return=representation');
$request->body = [
'intent' => 'CAPTURE',
'purchase_units' => [[
'amount' => [
'currency_code' => 'EUR',
'value' => number_format($package->price, 2, '.', ''),
],
'description' => 'Fotospiel Package: '.$package->name,
'custom_id' => json_encode([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'user_id' => $tenant->user_id ?? null,
]),
]],
'application_context' => [
'shipping_preference' => 'NO_SHIPPING',
'user_action' => 'PAY_NOW',
],
]; ];
try { $checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
$response = $client->execute($request);
$order = $response->result;
return response()->json([ return response()->json($checkout);
'orderID' => $order->id,
]);
} catch (\Exception $e) {
Log::error('PayPal order creation error: '.$e->getMessage());
throw ValidationException::withMessages(['payment' => 'PayPal-Bestellung fehlgeschlagen.']);
}
}
public function capturePayPalOrder(Request $request): JsonResponse
{
$request->validate([
'order_id' => 'required|string',
]);
$orderId = $request->order_id;
$environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment(
config('services.paypal.client_id'),
config('services.paypal.secret')
) : new LiveEnvironment(
config('services.paypal.client_id'),
config('services.paypal.secret')
);
$client = PayPalClient::client($environment);
$request = new OrdersCaptureRequest($orderId);
$request->prefer('return=representation');
try {
$response = $client->execute($request);
$capture = $response->result;
if ($capture->status !== 'COMPLETED') {
throw new \Exception('PayPal capture not completed: '.$capture->status);
}
$customId = $capture->purchaseUnits[0]->customId ?? null;
if (! $customId) {
throw new \Exception('No metadata in PayPal order');
}
$metadata = json_decode($customId, true);
$tenant = Tenant::find($metadata['tenant_id']);
$package = Package::find($metadata['package_id']);
if (! $tenant || ! $package) {
throw new \Exception('Tenant or package not found');
}
// Idempotent check
$existing = PackagePurchase::where('provider_id', $orderId)->first();
if ($existing) {
return response()->json(['success' => true, 'message' => 'Already processed']);
}
DB::transaction(function () use ($tenant, $package, $orderId) {
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $orderId,
'price' => $package->price,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'purchased_at' => now(),
'metadata' => json_encode(['paypal_order' => $orderId]),
]);
// Trial logic for first reseller subscription
$activePackages = TenantPackage::where('tenant_id', $tenant->id)
->where('active', true)
->where('package_id', '!=', $package->id) // Exclude current if renewing
->count();
$expiresAt = now()->addYear();
if ($activePackages === 0 && $package->type === 'reseller_subscription') {
$expiresAt = now()->addDays(14); // 14-day trial
Log::info('PayPal trial activated for tenant', ['tenant_id' => $tenant->id]);
}
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $package->id],
[
'price' => $package->price,
'purchased_at' => now(),
'active' => true,
'expires_at' => $expiresAt,
]
);
$tenant->update(['subscription_status' => 'active']);
});
Log::info('PayPal order captured successfully', ['order_id' => $orderId, 'tenant_id' => $tenant->id]);
return response()->json(['success' => true, 'message' => 'Payment successful']);
} catch (\Exception $e) {
Log::error('PayPal capture error: '.$e->getMessage(), ['order_id' => $orderId]);
return response()->json(['success' => false, 'message' => 'Capture failed: '.$e->getMessage()], 422);
}
} }
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
@@ -345,6 +220,7 @@ class PackageController extends Controller
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'event_id' => $request->event_id, 'event_id' => $request->event_id,
'package_id' => $package->id, 'package_id' => $package->id,
'provider' => 'free',
'provider_id' => 'free', 'provider_id' => 'free',
'price' => $package->price, 'price' => $package->price,
'type' => $request->type, 'type' => $request->type,
@@ -397,7 +273,4 @@ class PackageController extends Controller
return $response; return $response;
} }
} }
// Helper for PayPal client - add this if not exists, or use global
// But since SDK has PayPalClient, assume it's used
} }

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class TenantBillingController extends Controller
{
public function __construct(private readonly PaddleTransactionService $paddleTransactions) {}
public function transactions(Request $request): JsonResponse
{
$tenant = $request->attributes->get('tenant');
if (! $tenant) {
return response()->json([
'data' => [],
'message' => 'Tenant not found.',
], 404);
}
if (! $tenant->paddle_customer_id) {
return response()->json([
'data' => [],
'message' => 'Tenant has no Paddle customer identifier.',
]);
}
$cursor = $request->query('cursor');
$perPage = (int) $request->query('per_page', 25);
$query = [
'per_page' => max(1, min($perPage, 100)),
];
if ($cursor) {
$query['after'] = $cursor;
}
try {
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query);
} catch (\Throwable $exception) {
Log::warning('Failed to load Paddle transactions', [
'tenant_id' => $tenant->id,
'error' => $exception->getMessage(),
]);
return response()->json([
'data' => [],
'message' => 'Failed to load Paddle transactions.',
], 502);
}
return response()->json([
'data' => $result['data'],
'meta' => $result['meta'],
]);
}
}

View File

@@ -3,19 +3,18 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use App\Models\Tenant;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\App;
class RegisteredUserController extends Controller class RegisteredUserController extends Controller
{ {
@@ -123,6 +122,7 @@ class RegisteredUserController extends Controller
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'price' => 0, 'price' => 0,
'purchased_at' => now(), 'purchased_at' => now(),
'provider' => 'free',
'provider_id' => 'free', 'provider_id' => 'free',
]); ]);
@@ -146,8 +146,3 @@ class RegisteredUserController extends Controller
return Inertia::location(route('verification.notice')); return Inertia::location(route('verification.notice'));
} }
} }

View File

@@ -7,22 +7,16 @@ use App\Models\AbandonedCheckout;
use App\Models\Package; use App\Models\Package;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Inertia\Inertia; use Inertia\Inertia;
use Illuminate\Support\Str;
use Stripe\PaymentIntent;
use Stripe\Stripe;
use App\Http\Controllers\PayPalController;
use App\Support\Concerns\PresentsPackages;
class CheckoutController extends Controller class CheckoutController extends Controller
{ {
@@ -32,6 +26,7 @@ class CheckoutController extends Controller
{ {
$googleStatus = session()->pull('checkout_google_status'); $googleStatus = session()->pull('checkout_google_status');
$googleError = session()->pull('checkout_google_error'); $googleError = session()->pull('checkout_google_error');
$googleProfile = session()->pull('checkout_google_profile');
$packageOptions = Package::orderBy('price')->get() $packageOptions = Package::orderBy('price')->get()
->map(fn (Package $pkg) => $this->presentPackage($pkg)) ->map(fn (Package $pkg) => $this->presentPackage($pkg))
@@ -41,8 +36,6 @@ class CheckoutController extends Controller
return Inertia::render('marketing/CheckoutWizardPage', [ return Inertia::render('marketing/CheckoutWizardPage', [
'package' => $this->presentPackage($package), 'package' => $this->presentPackage($package),
'packageOptions' => $packageOptions, 'packageOptions' => $packageOptions,
'stripePublishableKey' => config('services.stripe.key'),
'paypalClientId' => config('services.paypal.client_id'),
'privacyHtml' => view('legal.datenschutz-partial')->render(), 'privacyHtml' => view('legal.datenschutz-partial')->render(),
'auth' => [ 'auth' => [
'user' => Auth::user(), 'user' => Auth::user(),
@@ -50,6 +43,11 @@ class CheckoutController extends Controller
'googleAuth' => [ 'googleAuth' => [
'status' => $googleStatus, 'status' => $googleStatus,
'error' => $googleError, 'error' => $googleError,
'profile' => $googleProfile,
],
'paddle' => [
'environment' => config('paddle.environment'),
'client_token' => config('paddle.client_token'),
], ],
]); ]);
} }
@@ -58,9 +56,16 @@ class CheckoutController extends Controller
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'required|email|unique:users,email', 'email' => 'required|email|unique:users,email',
'username' => 'required|string|max:255|unique:users,username',
'password' => ['required', 'confirmed', Password::defaults()], 'password' => ['required', 'confirmed', Password::defaults()],
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'address' => 'required|string|max:500',
'phone' => 'required|string|max:255',
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
'terms' => 'required|accepted', 'terms' => 'required|accepted',
'privacy_consent' => 'required|accepted',
'locale' => 'nullable|string|max:10',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@@ -76,6 +81,13 @@ class CheckoutController extends Controller
// User erstellen // User erstellen
$user = User::create([ $user = User::create([
'email' => $request->email, 'email' => $request->email,
'username' => $validated['username'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'name' => trim($validated['first_name'].' '.$validated['last_name']),
'address' => $validated['address'],
'phone' => $validated['phone'],
'preferred_locale' => $validated['locale'] ?? null,
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
'pending_purchase' => true, 'pending_purchase' => true,
]); ]);
@@ -156,7 +168,7 @@ class CheckoutController extends Controller
if (! $user || ! Hash::check($request->password, $user->password)) { if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json([ return response()->json([
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']] 'errors' => ['identifier' => ['Ungültige Anmeldedaten.']],
], 422); ], 422);
} }
@@ -242,165 +254,6 @@ class CheckoutController extends Controller
return response()->json(['status' => 'tracked']); return response()->json(['status' => 'tracked']);
} }
public function createPaymentIntent(Request $request)
{
$request->validate([
'package_id' => 'required|exists:packages,id',
]);
$package = Package::findOrFail($request->package_id);
\Log::info('Create Payment Intent', [
'package_id' => $package->id,
'package_name' => $package->name,
'price' => $package->price,
'is_free' => $package->is_free,
'user_id' => Auth::id(),
]);
$isFreePackage = $this->packageIsFree($package);
if ($isFreePackage) {
\Log::info('Free package detected, returning null client_secret');
return response()->json([
'client_secret' => null,
'free_package' => true,
]);
}
// Stripe API Key setzen
Stripe::setApiKey(config('services.stripe.secret'));
try {
$paymentIntent = PaymentIntent::create([
'amount' => $package->price * 100, // Stripe erwartet Cent
'currency' => 'eur',
'metadata' => [
'package_id' => $package->id,
'user_id' => Auth::id(),
],
]);
\Log::info('PaymentIntent created successfully', [
'payment_intent_id' => $paymentIntent->id,
'client_secret' => substr($paymentIntent->client_secret, 0, 50) . '...',
]);
return response()->json([
'client_secret' => $paymentIntent->client_secret,
]);
} catch (\Exception $e) {
\Log::error('Stripe PaymentIntent creation failed', [
'error' => $e->getMessage(),
'package_id' => $package->id,
]);
return response()->json([
'error' => 'Fehler beim Erstellen der Zahlungsdaten: ' . $e->getMessage(),
], 500);
}
}
public function confirmPayment(Request $request)
{
$request->validate([
'payment_intent_id' => 'required|string',
'package_id' => 'required|exists:packages,id',
]);
// Stripe API Key setzen
Stripe::setApiKey(config('services.stripe.secret'));
$paymentIntent = PaymentIntent::retrieve($request->payment_intent_id);
if ($paymentIntent->status !== 'succeeded') {
return response()->json([
'error' => 'Zahlung nicht erfolgreich.',
], 400);
}
$package = Package::findOrFail($request->package_id);
$user = Auth::user();
// Package dem Tenant zuweisen
$user->tenant->packages()->attach($package->id, [
'price' => $package->price,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => true,
]);
// pending_purchase zurücksetzen
$user->update(['pending_purchase' => false]);
return response()->json([
'message' => 'Zahlung erfolgreich bestätigt.',
]);
}
public function handlePayPalReturn(Request $request)
{
$orderId = $request->query('orderID');
if (!$orderId) {
return redirect('/checkout')->with('error', 'Ungültige PayPal-Rückkehr.');
}
$user = Auth::user();
if (!$user) {
return redirect('/login')->with('error', 'Bitte melden Sie sich an.');
}
try {
// Capture aufrufen
$paypalController = new PayPalController();
$captureRequest = new Request(['order_id' => $orderId]);
$captureResponse = $paypalController->captureOrder($captureRequest);
if ($captureResponse->getStatusCode() !== 200 || !isset($captureResponse->getData(true)['status']) || $captureResponse->getData(true)['status'] !== 'captured') {
Log::error('PayPal capture failed in return handler', ['order_id' => $orderId, 'response' => $captureResponse->getData(true)]);
return redirect('/checkout')->with('error', 'Zahlung konnte nicht abgeschlossen werden.');
}
// PackagePurchase finden (erzeugt durch captureOrder)
$purchase = \App\Models\PackagePurchase::where('provider_id', $orderId)
->where('tenant_id', $user->tenant_id)
->latest()
->first();
if (!$purchase) {
Log::error('No PackagePurchase found after PayPal capture', ['order_id' => $orderId, 'tenant_id' => $user->tenant_id]);
return redirect('/checkout')->with('error', 'Kauf konnte nicht verifiziert werden.');
}
$package = \App\Models\Package::find($purchase->package_id);
if (!$package) {
return redirect('/checkout')->with('error', 'Paket nicht gefunden.');
}
// TenantPackage zuweisen (ähnlich Stripe)
$user->tenant->packages()->attach($package->id, [
'price' => $package->price,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => true,
]);
// pending_purchase zurücksetzen
$user->update(['pending_purchase' => false]);
Log::info('PayPal payment completed and package assigned', ['order_id' => $orderId, 'package_id' => $package->id, 'tenant_id' => $user->tenant_id]);
return redirect('/success/' . $package->id)->with('success', 'Zahlung erfolgreich! Ihr Paket wurde aktiviert.');
} catch (\Exception $e) {
Log::error('Error in PayPal return handler', ['order_id' => $orderId, 'error' => $e->getMessage()]);
return redirect('/checkout')->with('error', 'Fehler beim Abschließen der Zahlung: ' . $e->getMessage());
}
}
private function packageIsFree(Package $package): bool private function packageIsFree(Package $package): bool
{ {
if (isset($package->is_free)) { if (isset($package->is_free)) {

View File

@@ -2,17 +2,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Mail\Welcome;
use App\Models\Package; use App\Models\Package;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -52,19 +48,45 @@ class CheckoutGoogleController extends Controller
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::warning('Google checkout login failed', ['message' => $e->getMessage()]); Log::warning('Google checkout login failed', ['message' => $e->getMessage()]);
$this->flashError($request, __('checkout.google_error_fallback')); $this->flashError($request, __('checkout.google_error_fallback'));
return $this->redirectBackToWizard($packageId); return $this->redirectBackToWizard($packageId);
} }
$email = $googleUser->getEmail(); $email = $googleUser->getEmail();
if (! $email) { if (! $email) {
$this->flashError($request, __('checkout.google_missing_email')); $this->flashError($request, __('checkout.google_missing_email'));
return $this->redirectBackToWizard($packageId); return $this->redirectBackToWizard($packageId);
} }
$user = DB::transaction(function () use ($googleUser, $email) { $raw = $googleUser->getRaw();
$request->session()->put('checkout_google_profile', array_filter([
'email' => $email,
'name' => $googleUser->getName(),
'given_name' => $raw['given_name'] ?? null,
'family_name' => $raw['family_name'] ?? null,
'avatar' => $googleUser->getAvatar(),
'locale' => $raw['locale'] ?? null,
]));
$existing = User::where('email', $email)->first(); $existing = User::where('email', $email)->first();
if ($existing) { if (! $existing) {
$request->session()->put('checkout_google_profile', array_filter([
'email' => $email,
'name' => $googleUser->getName(),
'given_name' => $raw['given_name'] ?? null,
'family_name' => $raw['family_name'] ?? null,
'avatar' => $googleUser->getAvatar(),
'locale' => $raw['locale'] ?? null,
]));
$request->session()->put('checkout_google_status', 'prefill');
return $this->redirectBackToWizard($packageId);
}
$user = DB::transaction(function () use ($existing, $googleUser, $email) {
$existing->forceFill([ $existing->forceFill([
'name' => $googleUser->getName() ?: $existing->name, 'name' => $googleUser->getName() ?: $existing->name,
'pending_purchase' => true, 'pending_purchase' => true,
@@ -76,32 +98,6 @@ class CheckoutGoogleController extends Controller
} }
return $existing->fresh(); 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)
->locale($user->preferred_locale ?? app()->getLocale())
->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) { if (! $user->tenant) {
@@ -111,7 +107,8 @@ class CheckoutGoogleController extends Controller
Auth::login($user, true); Auth::login($user, true);
$request->session()->regenerate(); $request->session()->regenerate();
$request->session()->forget(self::SESSION_KEY); $request->session()->forget(self::SESSION_KEY);
$request->session()->put('checkout_google_status', 'success'); $request->session()->forget('checkout_google_profile');
$request->session()->put('checkout_google_status', 'signin');
if ($packageId) { if ($packageId) {
$this->ensurePackageAttached($user, (int) $packageId); $this->ensurePackageAttached($user, (int) $packageId);

View File

@@ -3,42 +3,36 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Mail\ContactConfirmation; use App\Mail\ContactConfirmation;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Stripe\Stripe;
use Stripe\StripeClient;
use Exception;
use PayPalHttp\Client;
use PayPalHttp\HttpException;
use PayPalCheckout\OrdersCreateRequest;
use PayPalCheckout\OrdersCaptureRequest;
use App\Models\Tenant;
use App\Models\BlogPost; use App\Models\BlogPost;
use App\Models\CheckoutSession;
use App\Models\Package; use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\PaddleCheckoutService;
use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\Autolink\AutolinkExtension; use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension; use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension; use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter; use League\CommonMark\MarkdownConverter;
use App\Support\Concerns\PresentsPackages;
class MarketingController extends Controller class MarketingController extends Controller
{ {
use PresentsPackages; use PresentsPackages;
public function __construct() public function __construct(
{ private readonly CheckoutSessionService $checkoutSessions,
Stripe::setApiKey(config('services.stripe.key')); private readonly PaddleCheckoutService $paddleCheckout,
} ) {}
public function index() public function index()
{ {
@@ -69,7 +63,7 @@ class MarketingController extends Controller
'email' => $request->email, 'email' => $request->email,
'message' => $request->message, 'message' => $request->message,
], $locale), ], $locale),
function ($message) use ($request, $contactAddress, $locale) { function ($message) use ($contactAddress, $locale) {
$message->to($contactAddress) $message->to($contactAddress)
->subject(__('emails.contact.subject', [], $locale)); ->subject(__('emails.contact.subject', [], $locale));
} }
@@ -94,7 +88,7 @@ class MarketingController extends Controller
*/ */
public function buyPackages(Request $request, $packageId) public function buyPackages(Request $request, $packageId)
{ {
Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId, 'provider' => $request->input('provider', 'stripe')]); Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId]);
$package = Package::findOrFail($packageId); $package = Package::findOrFail($packageId);
if (! Auth::check()) { if (! Auth::check()) {
@@ -130,6 +124,7 @@ class MarketingController extends Controller
PackagePurchase::create([ PackagePurchase::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
'provider' => 'free',
'provider_id' => 'free', 'provider_id' => 'free',
'price' => $package->price, 'price' => $package->price,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
@@ -140,246 +135,49 @@ class MarketingController extends Controller
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned')); return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
} }
if ($package->type === 'reseller') { if (! $package->paddle_price_id) {
return $this->stripeSubscription($request, $packageId); Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
return redirect()->route('packages', ['highlight' => $package->slug])
->with('error', __('marketing.packages.paddle_not_configured'));
} }
if ($request->input('provider') === 'paypal') { $session = $this->checkoutSessions->createOrResume($user, $package, [
return $this->paypalCheckout($request, $packageId); 'tenant' => $tenant,
} ]);
return $this->checkout($request, $packageId); $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
}
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
'success_url' => route('marketing.success', ['packageId' => $package->id]),
/** 'return_url' => route('packages', ['highlight' => $package->slug]),
* Checkout for Stripe with auth metadata.
*/
public function checkout(Request $request, $packageId)
{
$package = Package::findOrFail($packageId);
$user = Auth::user();
$tenant = $user->tenant;
$stripe = new StripeClient(config('services.stripe.secret'));
$session = $stripe->checkout->sessions->create([
'payment_method_types' => ['card'],
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => $package->name,
],
'unit_amount' => $package->price * 100,
],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('marketing.success', $packageId),
'cancel_url' => route('packages'),
'metadata' => [ 'metadata' => [
'user_id' => $user->id, 'checkout_session_id' => $session->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
], ],
]); ]);
Log::info('Stripe Checkout initiated', ['package_id' => $packageId, 'session_id' => $session->id, 'tenant_id' => $tenant->id]); $session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
return redirect($session->url, 303); $redirectUrl = $checkout['checkout_url'] ?? null;
}
/** if (! $redirectUrl) {
* PayPal checkout with v2 Orders API (one-time payment). throw ValidationException::withMessages([
*/ 'paddle' => __('marketing.packages.paddle_checkout_failed'),
public function paypalCheckout(Request $request, $packageId)
{
$package = Package::findOrFail($packageId);
$user = Auth::user();
$tenant = $user->tenant;
$client = Client::create([
'clientId' => config('services.paypal.client_id'),
'clientSecret' => config('services.paypal.secret'),
'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live',
]); ]);
$ordersController = $client->orders();
$metadata = json_encode([
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
]);
$createRequest = new OrdersCreateRequest();
$createRequest->prefer('return=representation');
$createRequest->body = [
"intent" => "CAPTURE",
"purchase_units" => [[
"amount" => [
"currency_code" => "EUR",
"value" => number_format($package->price, 2, '.', ''),
],
"description" => "Package: " . $package->name,
"custom_id" => $metadata,
]],
"application_context" => [
"return_url" => route('marketing.success', $packageId),
"cancel_url" => route('packages'),
],
];
try {
$response = $ordersController->createOrder($createRequest);
$order = $response->result;
Log::info('PayPal Checkout initiated', ['package_id' => $packageId, 'order_id' => $order->id, 'tenant_id' => $tenant->id]);
session(['paypal_order_id' => $order->id]);
foreach ($order->links as $link) {
if ($link->rel === 'approve') {
return redirect($link->href);
}
} }
throw new Exception('No approve link found'); return redirect()->away($redirectUrl);
} catch (HttpException $e) {
Log::error('PayPal Orders API error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
} catch (Exception $e) {
Log::error('PayPal checkout error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
}
} }
/**
* Stripe subscription checkout for reseller packages.
*/
public function stripeSubscription(Request $request, $packageId)
{
$package = Package::findOrFail($packageId);
$user = Auth::user();
$tenant = $user->tenant;
$stripe = new StripeClient(config('services.stripe.secret'));
$session = $stripe->checkout->sessions->create([
'payment_method_types' => ['card'],
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => $package->name . ' (Annual Subscription)',
],
'unit_amount' => $package->price * 100,
'recurring' => [
'interval' => 'year',
'interval_count' => 1,
],
],
'quantity' => 1,
]],
'mode' => 'subscription',
'success_url' => route('marketing.success', $packageId),
'cancel_url' => route('packages'),
'metadata' => [
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
'subscription' => 'true',
],
]);
return redirect($session->url, 303);
}
public function stripeCheckout($sessionId)
{
// Handle Stripe success
return view('marketing.success', ['provider' => 'Stripe']);
}
/**
* Handle success after payment (capture PayPal, redirect if verified).
*/
public function success(Request $request, $packageId = null) public function success(Request $request, $packageId = null)
{ {
$provider = session('paypal_order_id') ? 'paypal' : 'stripe';
Log::info('Payment Success: Provider processed', ['provider' => $provider, 'package_id' => $packageId]);
if (session('paypal_order_id')) {
$orderId = session('paypal_order_id');
$client = Client::create([
'clientId' => config('services.paypal.client_id'),
'clientSecret' => config('services.paypal.secret'),
'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live',
]);
$ordersController = $client->orders();
$captureRequest = new OrdersCaptureRequest($orderId);
$captureRequest->prefer('return=minimal');
try {
$captureResponse = $ordersController->captureOrder($captureRequest);
$capture = $captureResponse->result;
Log::info('PayPal Capture completed', ['order_id' => $orderId, 'status' => $capture->status]);
if ($capture->status === 'COMPLETED') {
$customId = $capture->purchaseUnits[0]->customId ?? null;
if ($customId) {
$metadata = json_decode($customId, true);
$package = Package::find($metadata['package_id']);
$tenant = Tenant::find($metadata['tenant_id']);
if ($package && $tenant) {
TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'price' => $package->price,
'active' => true,
'purchased_at' => now(),
'expires_at' => now()->addYear(), // One-time as annual for reseller too
]
);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => 'paypal',
'price' => $package->price,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'purchased_at' => now(),
'refunded' => false,
]);
session()->forget('paypal_order_id');
$request->session()->flash('success', __('marketing.packages.purchased_successfully', ['name' => $package->name]));
}
}
} else {
Log::error('PayPal capture failed: ' . $capture->status);
$request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.');
}
} catch (HttpException $e) {
Log::error('PayPal capture error: ' . $e->getMessage());
$request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.');
} catch (\Exception $e) {
Log::error('PayPal success error: ' . $e->getMessage());
$request->session()->flash('error', 'Fehler beim Abschliessen der Zahlung.');
}
}
// Common logic: Redirect to admin if verified
if (Auth::check() && Auth::user()->email_verified_at) { if (Auth::check() && Auth::user()->email_verified_at) {
return redirect('/event-admin')->with('success', __('marketing.success.welcome')); return redirect('/event-admin')->with('success', __('marketing.success.welcome'));
} }
@@ -392,7 +190,7 @@ class MarketingController extends Controller
$locale = $request->get('locale', app()->getLocale()); $locale = $request->get('locale', app()->getLocale());
Log::info('Blog Index Debug - Initial', [ Log::info('Blog Index Debug - Initial', [
'locale' => $locale, 'locale' => $locale,
'full_url' => $request->fullUrl() 'full_url' => $request->fullUrl(),
]); ]);
$query = BlogPost::query() $query = BlogPost::query()
@@ -424,6 +222,7 @@ class MarketingController extends Controller
$post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? ''; $post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '';
$post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? ''; $post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '';
$post->content = $post->getTranslation('content', $locale) ?? $post->getTranslation('content', 'de') ?? ''; $post->content = $post->getTranslation('content', $locale) ?? $post->getTranslation('content', 'de') ?? '';
// Author name is a string, no translation needed; author is loaded via with('author') // Author name is a string, no translation needed; author is loaded via with('author')
return $post; return $post;
}); });
@@ -432,7 +231,7 @@ class MarketingController extends Controller
'count' => $posts->count(), 'count' => $posts->count(),
'total' => $posts->total(), 'total' => $posts->total(),
'posts_data' => $posts->toArray(), 'posts_data' => $posts->toArray(),
'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts' 'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts',
]); ]);
return Inertia::render('marketing/Blog', compact('posts')); return Inertia::render('marketing/Blog', compact('posts'));
@@ -456,12 +255,12 @@ class MarketingController extends Controller
// Transform to array with translated strings for the current locale // Transform to array with translated strings for the current locale
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? ''; $markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
$environment = new Environment(); $environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension()); $environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension()); $environment->addExtension(new TableExtension);
$environment->addExtension(new AutolinkExtension()); $environment->addExtension(new AutolinkExtension);
$environment->addExtension(new StrikethroughExtension()); $environment->addExtension(new StrikethroughExtension);
$environment->addExtension(new TaskListExtension()); $environment->addExtension(new TaskListExtension);
$converter = new MarkdownConverter($environment); $converter = new MarkdownConverter($environment);
$contentHtml = (string) $converter->convert($markdown); $contentHtml = (string) $converter->convert($markdown);
@@ -471,7 +270,7 @@ class MarketingController extends Controller
'type' => gettype($contentHtml), 'type' => gettype($contentHtml),
'is_string' => is_string($contentHtml), 'is_string' => is_string($contentHtml),
'length' => strlen($contentHtml ?? ''), 'length' => strlen($contentHtml ?? ''),
'preview' => substr((string)$contentHtml, 0, 200) . '...' 'preview' => substr((string) $contentHtml, 0, 200).'...',
]); ]);
$post = [ $post = [
@@ -484,7 +283,7 @@ class MarketingController extends Controller
'published_at' => $postModel->published_at->toDateString(), 'published_at' => $postModel->published_at->toDateString(),
'slug' => $postModel->slug, 'slug' => $postModel->slug,
'author' => $postModel->author ? [ 'author' => $postModel->author ? [
'name' => $postModel->author->name 'name' => $postModel->author->name,
] : null, ] : null,
]; ];
@@ -527,7 +326,7 @@ class MarketingController extends Controller
'locale' => app()->getLocale(), 'locale' => app()->getLocale(),
'url' => request()->fullUrl(), 'url' => request()->fullUrl(),
'route' => request()->route()->getName(), 'route' => request()->route()->getName(),
'isInertia' => request()->header('X-Inertia') 'isInertia' => request()->header('X-Inertia'),
]); ]);
$validTypes = ['hochzeit', 'geburtstag', 'firmenevent']; $validTypes = ['hochzeit', 'geburtstag', 'firmenevent'];

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class PaddleCheckoutController extends Controller
{
public function __construct(
private readonly PaddleCheckoutService $checkout,
private readonly CheckoutSessionService $sessions,
) {}
public function create(Request $request): JsonResponse
{
$data = $request->validate([
'package_id' => ['required', 'exists:packages,id'],
'success_url' => ['nullable', 'url'],
'return_url' => ['nullable', 'url'],
'inline' => ['sometimes', 'boolean'],
]);
$user = Auth::user();
$tenant = $user?->tenant;
if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
}
$package = Package::findOrFail((int) $data['package_id']);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant,
]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
if ($request->boolean('inline')) {
$metadata = array_merge($session->provider_metadata ?? [], [
'mode' => 'inline',
]);
$session->forceFill([
'provider_metadata' => $metadata,
])->save();
return response()->json([
'mode' => 'inline',
'items' => [
[
'priceId' => $package->paddle_price_id,
'quantity' => 1,
],
],
'custom_data' => [
'tenant_id' => (string) $tenant->id,
'package_id' => (string) $package->id,
'checkout_session_id' => (string) $session->id,
],
'customer' => array_filter([
'email' => $user->email,
'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null),
]),
]);
}
$checkout = $this->checkout->createCheckout($tenant, $package, [
'success_url' => $data['success_url'] ?? null,
'return_url' => $data['return_url'] ?? null,
'metadata' => [
'checkout_session_id' => $session->id,
],
]);
$session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
return response()->json($checkout);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers;
use App\Services\Checkout\CheckoutWebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class PaddleWebhookController extends Controller
{
public function __construct(private readonly CheckoutWebhookService $webhooks) {}
public function handle(Request $request): JsonResponse
{
if (! $this->verify($request)) {
Log::warning('Paddle webhook signature verification failed');
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
}
$payload = $request->json()->all();
if (! is_array($payload)) {
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
}
$eventType = $payload['event_type'] ?? null;
$handled = false;
if ($eventType) {
$handled = $this->webhooks->handlePaddleEvent($payload);
}
Log::info('Paddle webhook processed', [
'event_type' => $eventType,
'handled' => $handled,
]);
$statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED;
return response()->json([
'status' => $handled ? 'processed' : 'ignored',
], $statusCode);
}
protected function verify(Request $request): bool
{
$secret = config('paddle.webhook_secret');
if (! $secret) {
// Allow processing in sandbox or when secret not configured
return true;
}
$signature = (string) $request->headers->get('Paddle-Webhook-Signature', '');
if ($signature === '') {
return false;
}
$payload = $request->getContent();
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}
}

View File

@@ -1,264 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\Package;
use PaypalServerSdkLib\Models\Builders\OrderRequestBuilder;
use PaypalServerSdkLib\Models\Builders\PurchaseUnitRequestBuilder;
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
{
private $client;
private PaypalClientFactory $clientFactory;
public function __construct(PaypalClientFactory $clientFactory)
{
$this->clientFactory = $clientFactory;
$this->client = $clientFactory->make();
}
public function createOrder(Request $request)
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'tenant_id' => 'nullable|exists:tenants,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();
$body = OrderRequestBuilder::init(
CheckoutPaymentIntent::CAPTURE,
[
PurchaseUnitRequestBuilder::init(
AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', ''))
->build()
)
->description('Package: ' . $package->name)
->customId(json_encode([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => 'endcustomer_event'
]))
->build()
]
)
->applicationContext(
OrderApplicationContextBuilder::init()
->brandName('Fotospiel')
->landingPage('BILLING')
->build()
)
->build();
$collect = [
'body' => $body,
'prefer' => 'return=representation'
];
try {
$response = $ordersController->createOrder($collect);
if ($response->getStatusCode() === 201) {
$result = $response->getResult();
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
return response()->json([
'id' => $result->id,
'approve_url' => $approveLink,
]);
}
Log::error('PayPal order creation failed', ['response' => $response]);
return response()->json(['error' => 'Order creation failed'], 400);
} catch (\Exception $e) {
Log::error('PayPal order creation exception', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Order creation failed'], 500);
}
}
public function captureOrder(Request $request)
{
$request->validate(['order_id' => 'required']);
$ordersController = $this->client->getOrdersController();
$collect = [
'id' => $request->order_id,
'prefer' => 'return=representation'
];
try {
$response = $ordersController->captureOrder($collect);
if ($response->getStatusCode() === 201) {
$result = $response->getResult();
$customId = $result->purchaseUnits[0]->customId ?? null;
if ($customId) {
$metadata = json_decode($customId, true);
$tenantId = $metadata['tenant_id'] ?? null;
$packageId = $metadata['package_id'] ?? null;
$type = $metadata['type'] ?? 'endcustomer_event';
if ($tenantId && $packageId) {
$tenant = Tenant::findOrFail($tenantId);
$package = Package::findOrFail($packageId);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $result->id,
'price' => $result->purchaseUnits[0]->amount->value,
'type' => $type,
'purchased_at' => now(),
'refunded' => false,
]);
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => $package->price,
'purchased_at' => now(),
'active' => true,
]);
$tenant->update(['subscription_status' => 'active']);
} else {
Log::error('Invalid metadata in PayPal custom_id', ['custom_id' => $customId]);
}
Log::info('PayPal order captured and purchase created: ' . $result->id);
}
return response()->json(['status' => 'captured', 'order' => $result]);
}
Log::error('PayPal order capture failed', ['response' => $response]);
return response()->json(['error' => 'Capture failed'], 400);
} catch (\Exception $e) {
Log::error('PayPal order capture exception', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Capture failed'], 500);
}
}
public function createSubscription(Request $request)
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'plan_id' => 'required|string',
'tenant_id' => 'nullable|exists:tenants,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();
$storedPaymentSource = new \PaypalServerSdkLib\Models\StoredPaymentSource(
'CUSTOMER',
'RECURRING'
);
$storedPaymentSource->setUsage('FIRST');
$paymentSource = new \PaypalServerSdkLib\Models\PaymentSource();
$paymentSource->storedPaymentSource = $storedPaymentSource;
$body = OrderRequestBuilder::init(
CheckoutPaymentIntent::CAPTURE,
[
PurchaseUnitRequestBuilder::init(
AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', ''))
->build()
)
->description('Subscription Package: ' . $package->name)
->customId(json_encode([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => 'reseller_subscription',
'plan_id' => $request->plan_id
]))
->build()
]
)
->paymentSource($paymentSource)
->applicationContext(
OrderApplicationContextBuilder::init()
->brandName('Fotospiel')
->landingPage('BILLING')
->build()
)
->build();
$collect = [
'body' => $body,
'prefer' => 'return=representation'
];
try {
$response = $ordersController->createOrder($collect);
if ($response->getStatusCode() === 201) {
$result = $response->getResult();
$orderId = $result->id;
// Initial purchase record for subscription setup
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => $package->price,
'purchased_at' => now(),
'expires_at' => now()->addYear(), // Assuming annual subscription
'active' => true,
]);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $orderId . '_sub_' . $request->plan_id, // Combine for uniqueness
'price' => $package->price,
'type' => 'reseller_subscription',
'purchased_at' => now(),
]);
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
return response()->json([
'order_id' => $orderId,
'approve_url' => $approveLink,
]);
}
Log::error('PayPal subscription order creation failed', ['response' => $response]);
return response()->json(['error' => 'Subscription order creation failed'], 400);
} catch (\Exception $e) {
Log::error('PayPal subscription order creation exception', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Subscription order creation failed'], 500);
}
}
}

View File

@@ -1,269 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use PaypalServerSdkLib\Controllers\OrdersController;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Models\Tenant;
use App\Models\Package;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Services\PayPal\PaypalClientFactory;
use App\Services\Checkout\CheckoutWebhookService;
class PayPalWebhookController extends Controller
{
public function __construct(
private PaypalClientFactory $clientFactory,
private CheckoutWebhookService $checkoutWebhooks,
) {
}
public function verify(Request $request): JsonResponse
{
$request->validate([
'webhook_id' => 'required|string',
'webhook_event' => 'required|array',
]);
$webhookId = $request->webhook_id;
$event = $request->webhook_event;
$client = $this->clientFactory->make();
// Basic webhook validation - simplified for now
// TODO: Implement proper webhook signature verification with official SDK
$isValidWebhook = true; // Temporarily allow all webhooks for testing
try {
if ($isValidWebhook) {
// Process the webhook event
$this->handleEvent($event);
return response()->json(['status' => 'SUCCESS'], 200);
} else {
Log::warning('PayPal webhook verification failed', ['status' => 'basic_validation_failed']);
return response()->json(['status' => 'FAILURE'], 400);
}
} catch (\Exception $e) {
Log::error('PayPal webhook verification error: ' . $e->getMessage());
return response()->json(['status' => 'FAILURE'], 500);
}
}
private function handleEvent(array $event): void
{
$eventType = $event['event_type'] ?? '';
$resource = $event['resource'] ?? [];
Log::info('PayPal webhook received', ['event_type' => $eventType, 'resource_id' => $resource['id'] ?? 'unknown']);
if ($this->checkoutWebhooks->handlePayPalEvent($event)) {
return;
}
switch ($eventType) {
case 'CHECKOUT.ORDER.APPROVED':
// Handle order approval if needed
break;
case 'PAYMENT.CAPTURE.COMPLETED':
$this->handleCaptureCompleted($resource);
break;
case 'PAYMENT.CAPTURE.DENIED':
$this->handleCaptureDenied($resource);
break;
case 'BILLING.SUBSCRIPTION.ACTIVATED':
// Handle subscription activation for SaaS
$this->handleSubscriptionActivated($resource);
break;
case 'BILLING.SUBSCRIPTION.CANCELLED':
$this->handleSubscriptionCancelled($resource);
break;
default:
Log::info('Unhandled PayPal webhook event', ['event_type' => $eventType]);
}
}
private function handleCaptureCompleted(array $capture): void
{
$orderId = $capture['order_id'] ?? null;
if (!$orderId) {
Log::warning('No order_id in PayPal capture webhook', ['capture_id' => $capture['id'] ?? 'unknown']);
return;
}
// Idempotent check
$purchase = PackagePurchase::where('provider_id', $orderId)->first();
if ($purchase) {
Log::info('PayPal order already processed', ['order_id' => $orderId]);
return;
}
// Fetch order to get custom_id
$this->processPurchaseFromOrder($orderId, 'completed');
}
private function handleCaptureDenied(array $capture): void
{
$orderId = $capture['id'] ?? null;
Log::warning('PayPal capture denied', ['order_id' => $orderId]);
// Handle denial, e.g., notify tenant or refund logic if needed
// For now, log
}
private function handleSubscriptionActivated(array $subscription): void
{
$subscriptionId = $subscription['id'] ?? null;
if (!$subscriptionId) {
return;
}
// Update tenant subscription status
// Assume metadata has tenant_id
$customId = $subscription['custom_id'] ?? null;
if ($customId) {
$metadata = json_decode($customId, true);
$tenantId = $metadata['tenant_id'] ?? null;
if ($tenantId) {
$tenant = Tenant::find($tenantId);
if ($tenant) {
$tenant->update(['subscription_status' => 'active']);
Log::info('PayPal subscription activated', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]);
}
}
}
}
private function handleSubscriptionCancelled(array $subscription): void
{
$subscriptionId = $subscription['id'] ?? null;
if (!$subscriptionId) {
return;
}
// Update tenant to cancelled
$customId = $subscription['custom_id'] ?? null;
if ($customId) {
$metadata = json_decode($customId, true);
$tenantId = $metadata['tenant_id'] ?? null;
if ($tenantId) {
$tenant = Tenant::find($tenantId);
if ($tenant) {
$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]);
}
}
}
}
private function processPurchaseFromOrder(string $orderId, string $status): void
{
// Fetch order details
$client = $this->clientFactory->make();
$ordersController = $client->getOrdersController();
try {
$response = $ordersController->showOrder([
'id' => $orderId,
'prefer' => 'return=representation'
]);
$order = method_exists($response, 'getResult') ? $response->getResult() : ($response->result ?? null);
if (! $order) {
Log::error('No order payload returned for PayPal order', ['order_id' => $orderId]);
return;
}
$customId = $order->purchaseUnits[0]->customId ?? null;
if (!$customId) {
Log::error('No custom_id in PayPal order', ['order_id' => $orderId]);
return;
}
$metadata = json_decode($customId, true);
$tenantId = $metadata['tenant_id'] ?? null;
$packageId = $metadata['package_id'] ?? null;
if (!$tenantId || !$packageId) {
Log::error('Missing metadata in PayPal order', ['order_id' => $orderId, 'metadata' => $metadata]);
return;
}
$tenant = Tenant::find($tenantId);
$package = Package::find($packageId);
if (!$tenant || !$package) {
Log::error('Tenant or package not found for PayPal order', ['order_id' => $orderId]);
return;
}
$operation = function () use ($tenant, $package, $orderId, $status) {
// Idempotent check
$existing = PackagePurchase::where('provider_id', $orderId)->first();
if ($existing) {
return;
}
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $orderId,
'price' => $package->price,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'purchased_at' => now(),
'status' => $status,
'metadata' => json_encode(['paypal_order' => $orderId, 'webhook' => true]),
]);
// For trial: if first purchase and reseller, set trial
$activePackages = TenantPackage::where('tenant_id', $tenant->id)
->where('active', true)
->count();
$expiresAt = now()->addYear();
if ($activePackages === 0 && $package->type === 'reseller_subscription') {
$expiresAt = now()->addDays(14); // Trial
}
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $package->id],
[
'price' => $package->price,
'purchased_at' => now(),
'active' => true,
'expires_at' => $expiresAt,
]
);
$tenant->update(['subscription_status' => 'active']);
};
$connection = DB::connection();
if ($connection->getDriverName() === 'sqlite' && $connection->transactionLevel() > 0) {
$operation();
} else {
$connection->transaction($operation);
}
Log::info('PayPal purchase processed via webhook', ['order_id' => $orderId, 'tenant_id' => $tenantId, 'status' => $status]);
} catch (\Exception $e) {
Log::error('Error processing PayPal order in webhook: ' . $e->getMessage(), ['order_id' => $orderId]);
}
}
}

View File

@@ -39,6 +39,8 @@ class ContentSecurityPolicy
"'nonce-{$scriptNonce}'", "'nonce-{$scriptNonce}'",
'https://js.stripe.com', 'https://js.stripe.com',
'https://js.stripe.network', 'https://js.stripe.network',
'https://cdn.paddle.com',
'https://global.localizecdn.com',
]; ];
$styleSources = [ $styleSources = [
@@ -51,11 +53,22 @@ class ContentSecurityPolicy
"'self'", "'self'",
'https://api.stripe.com', 'https://api.stripe.com',
'https://api.stripe.network', 'https://api.stripe.network',
'https://api.paddle.com',
'https://sandbox-api.paddle.com',
'https://checkout.paddle.com',
'https://sandbox-checkout.paddle.com',
'https://checkout-service.paddle.com',
'https://sandbox-checkout-service.paddle.com',
'https://global.localizecdn.com',
]; ];
$frameSources = [ $frameSources = [
"'self'", "'self'",
'https://js.stripe.com', 'https://js.stripe.com',
'https://checkout.paddle.com',
'https://sandbox-checkout.paddle.com',
'https://checkout-service.paddle.com',
'https://sandbox-checkout-service.paddle.com',
]; ];
$imgSources = [ $imgSources = [

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Jobs;
use App\Models\Package;
use App\Services\Paddle\PaddleCatalogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Throwable;
class PullPackageFromPaddle implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(private readonly int $packageId) {}
public function handle(PaddleCatalogService $catalog): void
{
$package = Package::query()->find($this->packageId);
if (! $package) {
return;
}
if (! $package->paddle_product_id && ! $package->paddle_price_id) {
Log::warning('Paddle pull skipped for package without linkage', ['package_id' => $package->id]);
return;
}
try {
$product = $package->paddle_product_id ? $catalog->fetchProduct($package->paddle_product_id) : null;
$price = $package->paddle_price_id ? $catalog->fetchPrice($package->paddle_price_id) : null;
$snapshot = $package->paddle_snapshot ?? [];
$snapshot['remote'] = array_filter([
'product' => $product,
'price' => $price,
], static fn ($value) => $value !== null);
$package->forceFill([
'paddle_sync_status' => 'pulled',
'paddle_synced_at' => now(),
'paddle_snapshot' => $snapshot,
])->save();
Log::info('Paddle package pull completed', ['package_id' => $package->id]);
} catch (Throwable $exception) {
Log::error('Paddle package pull failed', [
'package_id' => $package->id,
'message' => $exception->getMessage(),
'exception' => $exception,
]);
$snapshot = $package->paddle_snapshot ?? [];
$snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [
'message' => $exception->getMessage(),
'class' => $exception::class,
]);
$package->forceFill([
'paddle_sync_status' => 'pull-failed',
'paddle_synced_at' => now(),
'paddle_snapshot' => $snapshot,
])->save();
throw $exception;
}
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Jobs;
use App\Models\Package;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleCatalogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Throwable;
class SyncPackageToPaddle implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* @param array{dry_run?: bool, product?: array<string, mixed>, price?: array<string, mixed>} $options
*/
public function __construct(private readonly int $packageId, private readonly array $options = []) {}
public function handle(PaddleCatalogService $catalog): void
{
$package = Package::query()->find($this->packageId);
if (! $package) {
return;
}
$dryRun = (bool) ($this->options['dry_run'] ?? false);
$productOverrides = Arr::get($this->options, 'product', []);
$priceOverrides = Arr::get($this->options, 'price', []);
if ($dryRun) {
$this->storeDryRunSnapshot($catalog, $package, $productOverrides, $priceOverrides);
return;
}
$package->forceFill([
'paddle_sync_status' => 'syncing',
])->save();
try {
$productResponse = $package->paddle_product_id
? $catalog->updateProduct($package->paddle_product_id, $package, $productOverrides)
: $catalog->createProduct($package, $productOverrides);
$productId = (string) ($productResponse['id'] ?? $package->paddle_product_id);
if (! $productId) {
throw new PaddleException('Paddle product ID missing after sync.');
}
$package->paddle_product_id = $productId;
$priceResponse = $package->paddle_price_id
? $catalog->updatePrice($package->paddle_price_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
: $catalog->createPrice($package, $productId, $priceOverrides);
$priceId = (string) ($priceResponse['id'] ?? $package->paddle_price_id);
if (! $priceId) {
throw new PaddleException('Paddle price ID missing after sync.');
}
$package->forceFill([
'paddle_price_id' => $priceId,
'paddle_sync_status' => 'synced',
'paddle_synced_at' => now(),
'paddle_snapshot' => [
'product' => $productResponse,
'price' => $priceResponse,
'payload' => [
'product' => $catalog->buildProductPayload($package, $productOverrides),
'price' => $catalog->buildPricePayload($package, $productId, $priceOverrides),
],
],
])->save();
} catch (Throwable $exception) {
Log::error('Paddle package sync failed', [
'package_id' => $package->id,
'message' => $exception->getMessage(),
'exception' => $exception,
]);
$package->forceFill([
'paddle_sync_status' => 'failed',
'paddle_synced_at' => now(),
'paddle_snapshot' => array_merge($package->paddle_snapshot ?? [], [
'error' => [
'message' => $exception->getMessage(),
'class' => $exception::class,
],
]),
])->save();
throw $exception;
}
}
/**
* @param array<string, mixed> $productOverrides
* @param array<string, mixed> $priceOverrides
*/
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
{
$productPayload = $catalog->buildProductPayload($package, $productOverrides);
$pricePayload = $catalog->buildPricePayload(
$package,
$package->paddle_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
$priceOverrides
);
$package->forceFill([
'paddle_sync_status' => 'dry-run',
'paddle_synced_at' => now(),
'paddle_snapshot' => [
'dry_run' => true,
'payload' => [
'product' => $productPayload,
'price' => $pricePayload,
],
],
])->save();
Log::info('Paddle package dry-run snapshot generated', [
'package_id' => $package->id,
]);
}
}

View File

@@ -15,16 +15,25 @@ class CheckoutSession extends Model
use SoftDeletes; use SoftDeletes;
public const STATUS_DRAFT = 'draft'; public const STATUS_DRAFT = 'draft';
public const STATUS_AWAITING_METHOD = 'awaiting_payment_method'; public const STATUS_AWAITING_METHOD = 'awaiting_payment_method';
public const STATUS_REQUIRES_CUSTOMER_ACTION = 'requires_customer_action'; public const STATUS_REQUIRES_CUSTOMER_ACTION = 'requires_customer_action';
public const STATUS_PROCESSING = 'processing'; public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed'; public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed'; public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled'; public const STATUS_CANCELLED = 'cancelled';
public const PROVIDER_NONE = 'none'; public const PROVIDER_NONE = 'none';
public const PROVIDER_STRIPE = 'stripe'; public const PROVIDER_STRIPE = 'stripe';
public const PROVIDER_PAYPAL = 'paypal';
public const PROVIDER_PADDLE = 'paddle';
public const PROVIDER_FREE = 'free'; public const PROVIDER_FREE = 'free';
/** /**

View File

@@ -2,10 +2,10 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Package extends Model class Package extends Model
{ {
@@ -29,6 +29,11 @@ class Package extends Model
'description', 'description',
'description_translations', 'description_translations',
'description_table', 'description_table',
'paddle_product_id',
'paddle_price_id',
'paddle_sync_status',
'paddle_synced_at',
'paddle_snapshot',
]; ];
protected $casts = [ protected $casts = [
@@ -45,9 +50,10 @@ class Package extends Model
'name_translations' => 'array', 'name_translations' => 'array',
'description_translations' => 'array', 'description_translations' => 'array',
'description_table' => 'array', 'description_table' => 'array',
'paddle_synced_at' => 'datetime',
'paddle_snapshot' => 'array',
]; ];
protected function features(): Attribute protected function features(): Attribute
{ {
return Attribute::make( return Attribute::make(
@@ -73,7 +79,6 @@ class Package extends Model
); );
} }
public function eventPackages(): HasMany public function eventPackages(): HasMany
{ {
return $this->hasMany(EventPackage::class); return $this->hasMany(EventPackage::class);

View File

@@ -16,6 +16,7 @@ class PackagePurchase extends Model
'tenant_id', 'tenant_id',
'event_id', 'event_id',
'package_id', 'package_id',
'provider',
'provider_id', 'provider_id',
'price', 'price',
'type', 'type',

View File

@@ -5,7 +5,6 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class TenantPackage extends Model class TenantPackage extends Model
{ {
@@ -16,6 +15,7 @@ class TenantPackage extends Model
protected $fillable = [ protected $fillable = [
'tenant_id', 'tenant_id',
'package_id', 'package_id',
'paddle_subscription_id',
'price', 'price',
'purchased_at', 'purchased_at',
'expires_at', 'expires_at',
@@ -57,6 +57,7 @@ class TenantPackage extends Model
} }
$maxEvents = $this->package->max_events_per_year ?? 0; $maxEvents = $this->package->max_events_per_year ?? 0;
return $this->used_events < $maxEvents; return $this->used_events < $maxEvents;
} }
@@ -67,6 +68,7 @@ class TenantPackage extends Model
} }
$max = $this->package->max_events_per_year ?? 0; $max = $this->package->max_events_per_year ?? 0;
return max(0, $max - $this->used_events); return max(0, $max - $this->used_events);
} }
@@ -85,7 +87,11 @@ class TenantPackage extends Model
}); });
static::updating(function ($tenantPackage) { static::updating(function ($tenantPackage) {
if ($tenantPackage->isDirty('expires_at') && $tenantPackage->expires_at->isPast()) { if (
$tenantPackage->isDirty('expires_at')
&& $tenantPackage->expires_at instanceof \Carbon\CarbonInterface
&& $tenantPackage->expires_at->isPast()
) {
$tenantPackage->active = false; $tenantPackage->active = false;
} }
}); });

View File

@@ -4,8 +4,8 @@ namespace App\Services\Checkout;
use App\Mail\PurchaseConfirmation; use App\Mail\PurchaseConfirmation;
use App\Mail\Welcome; use App\Mail\Welcome;
use App\Models\CheckoutSession;
use App\Models\AbandonedCheckout; use App\Models\AbandonedCheckout;
use App\Models\CheckoutSession;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\Tenant; use App\Models\Tenant;
@@ -47,10 +47,19 @@ class CheckoutAssignmentService
return; return;
} }
$metadata = $session->provider_metadata ?? [];
$providerReference = $options['provider_reference'] $providerReference = $options['provider_reference']
?? $metadata['paddle_transaction_id'] ?? null
?? $metadata['paddle_checkout_id'] ?? null
?? $session->stripe_payment_intent_id ?? $session->stripe_payment_intent_id
?? $session->paypal_order_id ?? CheckoutSession::PROVIDER_FREE;
?? 'free';
$providerName = $options['provider']
?? $session->provider
?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null)
?? ($session->stripe_payment_intent_id ? CheckoutSession::PROVIDER_STRIPE : null)
?? CheckoutSession::PROVIDER_FREE;
$purchase = PackagePurchase::updateOrCreate( $purchase = PackagePurchase::updateOrCreate(
[ [
@@ -59,6 +68,7 @@ class CheckoutAssignmentService
'provider_id' => $providerReference, 'provider_id' => $providerReference,
], ],
[ [
'provider' => $providerName,
'price' => $session->amount_total, 'price' => $session->amount_total,
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event', 'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
'purchased_at' => now(), 'purchased_at' => now(),
@@ -121,6 +131,7 @@ class CheckoutAssignmentService
if (! $user->tenant_id) { if (! $user->tenant_id) {
$user->forceFill(['tenant_id' => $user->tenant->getKey()])->save(); $user->forceFill(['tenant_id' => $user->tenant->getKey()])->save();
} }
return $user->tenant; return $user->tenant;
} }

View File

@@ -11,8 +11,7 @@ class CheckoutPaymentService
public function __construct( public function __construct(
private readonly CheckoutSessionService $sessions, private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment, private readonly CheckoutAssignmentService $assignment,
) { ) {}
}
public function initialiseStripe(CheckoutSession $session, array $payload = []): array public function initialiseStripe(CheckoutSession $session, array $payload = []): array
{ {
@@ -40,32 +39,6 @@ class CheckoutPaymentService
return $session; return $session;
} }
public function initialisePayPal(CheckoutSession $session, array $payload = []): array
{
if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) {
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
}
// TODO: integrate PayPal Orders API and return order id + approval link
return [
'session_id' => $session->id,
'status' => $session->status,
'message' => 'PayPal integration pending implementation.',
];
}
public function capturePayPal(CheckoutSession $session, array $payload = []): CheckoutSession
{
if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) {
throw new LogicException('Cannot capture PayPal payment on a non-PayPal session.');
}
// TODO: call PayPal capture endpoint and persist order/subscription identifiers
$this->sessions->markProcessing($session);
return $session;
}
public function finaliseFree(CheckoutSession $session): CheckoutSession public function finaliseFree(CheckoutSession $session): CheckoutSession
{ {
if ($session->provider !== CheckoutSession::PROVIDER_FREE) { if ($session->provider !== CheckoutSession::PROVIDER_FREE) {

View File

@@ -35,7 +35,7 @@ class CheckoutSessionService
return $existing; return $existing;
} }
$session = new CheckoutSession(); $session = new CheckoutSession;
$session->id = (string) Str::uuid(); $session->id = (string) Str::uuid();
$session->status = CheckoutSession::STATUS_DRAFT; $session->status = CheckoutSession::STATUS_DRAFT;
$session->provider = CheckoutSession::PROVIDER_NONE; $session->provider = CheckoutSession::PROVIDER_NONE;
@@ -69,8 +69,8 @@ class CheckoutSessionService
$session->stripe_payment_intent_id = null; $session->stripe_payment_intent_id = null;
$session->stripe_customer_id = null; $session->stripe_customer_id = null;
$session->stripe_subscription_id = null; $session->stripe_subscription_id = null;
$session->paypal_order_id = null; $session->paddle_checkout_id = null;
$session->paypal_subscription_id = null; $session->paddle_transaction_id = null;
$session->provider_metadata = []; $session->provider_metadata = [];
$session->failure_reason = null; $session->failure_reason = null;
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes); $session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
@@ -85,7 +85,11 @@ class CheckoutSessionService
{ {
$provider = strtolower($provider); $provider = strtolower($provider);
if (! in_array($provider, [CheckoutSession::PROVIDER_STRIPE, CheckoutSession::PROVIDER_PAYPAL, CheckoutSession::PROVIDER_FREE], true)) { if (! in_array($provider, [
CheckoutSession::PROVIDER_STRIPE,
CheckoutSession::PROVIDER_PADDLE,
CheckoutSession::PROVIDER_FREE,
], true)) {
throw new RuntimeException("Unsupported checkout provider [{$provider}]"); throw new RuntimeException("Unsupported checkout provider [{$provider}]");
} }
@@ -101,7 +105,7 @@ class CheckoutSessionService
return $session; return $session;
} }
public function markRequiresCustomerAction(CheckoutSession $session, string $reason = null): CheckoutSession public function markRequiresCustomerAction(CheckoutSession $session, ?string $reason = null): CheckoutSession
{ {
$session->status = CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION; $session->status = CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION;
$session->failure_reason = $reason; $session->failure_reason = $reason;

View File

@@ -3,17 +3,23 @@
namespace App\Services\Checkout; namespace App\Services\Checkout;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Services\Paddle\PaddleSubscriptionService;
use Carbon\Carbon;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class CheckoutWebhookService class CheckoutWebhookService
{ {
public function __construct( public function __construct(
private readonly CheckoutSessionService $sessions, private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment, private readonly CheckoutAssignmentService $assignment,
) { private readonly PaddleSubscriptionService $paddleSubscriptions,
} ) {}
public function handleStripeEvent(array $event): bool public function handleStripeEvent(array $event): bool
{ {
@@ -72,29 +78,37 @@ class CheckoutWebhookService
} }
} }
public function handlePayPalEvent(array $event): bool public function handlePaddleEvent(array $event): bool
{ {
$eventType = $event['event_type'] ?? null; $eventType = $event['event_type'] ?? null;
$resource = $event['resource'] ?? []; $data = $event['data'] ?? [];
if (! $eventType || ! is_array($resource)) { if (! $eventType || ! is_array($data)) {
return false; return false;
} }
$orderId = $resource['order_id'] ?? $resource['id'] ?? null; if (Str::startsWith($eventType, 'subscription.')) {
return $this->handlePaddleSubscriptionEvent($eventType, $data);
}
$session = $this->locatePayPalSession($resource, $orderId); $session = $this->locatePaddleSession($data);
if (! $session) { if (! $session) {
Log::info('[CheckoutWebhook] Paddle session not resolved', [
'event_type' => $eventType,
'transaction_id' => $data['id'] ?? null,
]);
return false; return false;
} }
$lockKey = "checkout:webhook:paypal:".($orderId ?: $session->id); $transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
$lockKey = 'checkout:webhook:paddle:'.($transactionId ?: $session->id);
$lock = Cache::lock($lockKey, 30); $lock = Cache::lock($lockKey, 30);
if (! $lock->get()) { if (! $lock->get()) {
Log::info('[CheckoutWebhook] PayPal lock busy', [ Log::info('[CheckoutWebhook] Paddle lock busy', [
'order_id' => $orderId, 'transaction_id' => $transactionId,
'session_id' => $session->id, 'session_id' => $session->id,
]); ]);
@@ -102,22 +116,29 @@ class CheckoutWebhookService
} }
try { try {
if ($transactionId) {
$session->forceFill([ $session->forceFill([
'paypal_order_id' => $orderId ?: $session->paypal_order_id, 'paddle_transaction_id' => $transactionId,
'provider' => CheckoutSession::PROVIDER_PAYPAL, 'provider' => CheckoutSession::PROVIDER_PADDLE,
])->save(); ])->save();
} elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
$session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->save();
}
$metadata = [ $metadata = [
'paypal_last_event' => $eventType, 'paddle_last_event' => $eventType,
'paypal_last_event_id' => $event['id'] ?? null, 'paddle_transaction_id' => $transactionId,
'paypal_last_update_at' => now()->toIso8601String(), 'paddle_status' => $data['status'] ?? null,
'paypal_order_id' => $orderId, 'paddle_last_update_at' => now()->toIso8601String(),
'paypal_capture_id' => $resource['id'] ?? null,
]; ];
if (! empty($data['checkout_id'])) {
$metadata['paddle_checkout_id'] = $data['checkout_id'];
}
$this->mergeProviderMetadata($session, $metadata); $this->mergeProviderMetadata($session, $metadata);
return $this->applyPayPalEvent($session, $eventType, $resource); return $this->applyPaddleEvent($session, $eventType, $data);
} finally { } finally {
$lock->release(); $lock->release();
} }
@@ -131,16 +152,19 @@ class CheckoutWebhookService
$this->sessions->markProcessing($session, [ $this->sessions->markProcessing($session, [
'stripe_intent_status' => $intent['status'] ?? null, 'stripe_intent_status' => $intent['status'] ?? null,
]); ]);
return true; return true;
case 'payment_intent.requires_action': case 'payment_intent.requires_action':
$reason = $intent['next_action']['type'] ?? 'requires_action'; $reason = $intent['next_action']['type'] ?? 'requires_action';
$this->sessions->markRequiresCustomerAction($session, $reason); $this->sessions->markRequiresCustomerAction($session, $reason);
return true; return true;
case 'payment_intent.payment_failed': case 'payment_intent.payment_failed':
$failure = $intent['last_payment_error']['message'] ?? 'payment_failed'; $failure = $intent['last_payment_error']['message'] ?? 'payment_failed';
$this->sessions->markFailed($session, $failure); $this->sessions->markFailed($session, $failure);
return true; return true;
case 'payment_intent.succeeded': case 'payment_intent.succeeded':
@@ -165,25 +189,30 @@ class CheckoutWebhookService
} }
} }
protected function applyPayPalEvent(CheckoutSession $session, string $eventType, array $resource): bool protected function applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool
{ {
$status = strtolower((string) ($data['status'] ?? ''));
switch ($eventType) { switch ($eventType) {
case 'CHECKOUT.ORDER.APPROVED': case 'transaction.created':
case 'transaction.processing':
$this->sessions->markProcessing($session, [ $this->sessions->markProcessing($session, [
'paypal_order_status' => $resource['status'] ?? null, 'paddle_status' => $status ?: null,
]); ]);
return true; return true;
case 'PAYMENT.CAPTURE.COMPLETED': case 'transaction.completed':
if ($session->status !== CheckoutSession::STATUS_COMPLETED) { if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
$this->sessions->markProcessing($session, [ $this->sessions->markProcessing($session, [
'paypal_order_status' => $resource['status'] ?? null, 'paddle_status' => $status ?: 'completed',
]); ]);
$this->assignment->finalise($session, [ $this->assignment->finalise($session, [
'source' => 'paypal_webhook', 'source' => 'paddle_webhook',
'paypal_order_id' => $resource['order_id'] ?? null, 'provider' => CheckoutSession::PROVIDER_PADDLE,
'paypal_capture_id' => $resource['id'] ?? null, 'provider_reference' => $data['id'] ?? null,
'payload' => $data,
]); ]);
$this->sessions->markCompleted($session, now()); $this->sessions->markCompleted($session, now());
@@ -191,8 +220,11 @@ class CheckoutWebhookService
return true; return true;
case 'PAYMENT.CAPTURE.DENIED': case 'transaction.failed':
$this->sessions->markFailed($session, 'paypal_capture_denied'); case 'transaction.cancelled':
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
$this->sessions->markFailed($session, $reason);
return true; return true;
default: default:
@@ -200,6 +232,169 @@ class CheckoutWebhookService
} }
} }
protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool
{
$subscriptionId = $data['id'] ?? null;
if (! $subscriptionId) {
return false;
}
$metadata = $data['metadata'] ?? [];
$tenant = $this->resolveTenantFromSubscription($data, $metadata, $subscriptionId);
if (! $tenant) {
Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [
'subscription_id' => $subscriptionId,
]);
return false;
}
$package = $this->resolvePackageFromSubscription($data, $metadata, $subscriptionId);
if (! $package) {
Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [
'subscription_id' => $subscriptionId,
]);
return false;
}
$status = strtolower((string) ($data['status'] ?? ''));
$expiresAt = $this->resolveSubscriptionExpiry($data);
$startedAt = $this->resolveSubscriptionStart($data);
$tenantPackage = TenantPackage::firstOrNew([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
]);
$tenantPackage->fill([
'paddle_subscription_id' => $subscriptionId,
'price' => $package->price,
]);
$tenantPackage->expires_at = $expiresAt ?? $tenantPackage->expires_at ?? $startedAt?->copy()->addYear();
$tenantPackage->purchased_at = $tenantPackage->purchased_at
?? $tenant->purchases()->where('package_id', $package->id)->latest('purchased_at')->value('purchased_at')
?? $startedAt;
$tenantPackage->active = $this->isSubscriptionActive($status);
$tenantPackage->save();
if ($eventType === 'subscription.cancelled' || $eventType === 'subscription.paused') {
$tenantPackage->forceFill(['active' => false])->save();
}
$tenant->forceFill([
'subscription_status' => $this->mapSubscriptionStatus($status),
'subscription_expires_at' => $expiresAt,
'paddle_customer_id' => $tenant->paddle_customer_id ?: ($data['customer_id'] ?? null),
])->save();
Log::info('[CheckoutWebhook] Paddle subscription event processed', [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'subscription_id' => $subscriptionId,
'event_type' => $eventType,
'status' => $status,
]);
return true;
}
protected function resolveTenantFromSubscription(array $data, array $metadata, string $subscriptionId): ?Tenant
{
if (isset($metadata['tenant_id'])) {
$tenant = Tenant::find((int) $metadata['tenant_id']);
if ($tenant) {
return $tenant;
}
}
$customerId = $data['customer_id'] ?? null;
if ($customerId) {
$tenant = Tenant::where('paddle_customer_id', $customerId)->first();
if ($tenant) {
return $tenant;
}
}
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
$customerId = Arr::get($subscription, 'data.customer_id');
if ($customerId) {
return Tenant::where('paddle_customer_id', $customerId)->first();
}
return null;
}
protected function resolvePackageFromSubscription(array $data, array $metadata, string $subscriptionId): ?Package
{
if (isset($metadata['package_id'])) {
$package = Package::find((int) $metadata['package_id']);
if ($package) {
return $package;
}
}
$priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id');
if ($priceId) {
$package = Package::where('paddle_price_id', $priceId)->first();
if ($package) {
return $package;
}
}
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
$priceId = Arr::get($subscription, 'data.items.0.price_id') ?? Arr::get($subscription, 'data.items.0.price.id');
if ($priceId) {
return Package::where('paddle_price_id', $priceId)->first();
}
return null;
}
protected function resolveSubscriptionExpiry(array $data): ?Carbon
{
$nextBilling = Arr::get($data, 'next_billing_date') ?? Arr::get($data, 'next_payment_date');
if ($nextBilling) {
return Carbon::parse($nextBilling);
}
$endsAt = Arr::get($data, 'billing_period_ends_at') ?? Arr::get($data, 'pays_out_at');
return $endsAt ? Carbon::parse($endsAt) : null;
}
protected function resolveSubscriptionStart(array $data): Carbon
{
$created = Arr::get($data, 'created_at') ?? Arr::get($data, 'activated_at');
return $created ? Carbon::parse($created) : now();
}
protected function isSubscriptionActive(string $status): bool
{
return in_array($status, ['active', 'trialing'], true);
}
protected function mapSubscriptionStatus(string $status): string
{
return match ($status) {
'active', 'trialing' => 'active',
'paused' => 'suspended',
'cancelled', 'past_due', 'halted' => 'expired',
default => 'free',
};
}
protected function mergeProviderMetadata(CheckoutSession $session, array $data): void protected function mergeProviderMetadata(CheckoutSession $session, array $data): void
{ {
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data); $session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
@@ -230,42 +425,45 @@ class CheckoutWebhookService
return null; return null;
} }
protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession protected function locatePaddleSession(array $data): ?CheckoutSession
{ {
if ($orderId) { $metadata = $data['metadata'] ?? [];
if (is_array($metadata)) {
$sessionId = $metadata['checkout_session_id'] ?? null;
if ($sessionId && $session = CheckoutSession::find($sessionId)) {
return $session;
}
$tenantId = $metadata['tenant_id'] ?? null;
$packageId = $metadata['package_id'] ?? null;
if ($tenantId && $packageId) {
$session = CheckoutSession::query() $session = CheckoutSession::query()
->where('paypal_order_id', $orderId) ->where('tenant_id', $tenantId)
->where('package_id', $packageId)
->whereNotIn('status', [CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_CANCELLED])
->latest()
->first(); ->first();
if ($session) { if ($session) {
return $session; return $session;
} }
} }
}
$metadata = $this->extractPayPalMetadata($resource); $checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id');
$sessionId = $metadata['checkout_session_id'] ?? null;
if ($sessionId) { if ($checkoutId) {
return CheckoutSession::find($sessionId); return CheckoutSession::query()
->where('provider_metadata->paddle_checkout_id', $checkoutId)
->first();
} }
return null; return null;
} }
protected function extractPayPalMetadata(array $resource): array
{
$customId = $resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? null);
if ($customId) {
$decoded = json_decode($customId, true);
if (is_array($decoded)) {
return $decoded;
}
}
$meta = Arr::get($resource, 'supplementary_data.related_ids', []);
return is_array($meta) ? $meta : [];
}
protected function extractStripeChargeId(array $intent): ?string protected function extractStripeChargeId(array $intent): ?string
{ {
$charges = $intent['charges']['data'] ?? null; $charges = $intent['charges']['data'] ?? null;
@@ -276,4 +474,3 @@ class CheckoutWebhookService
return null; return null;
} }
} }

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services\Paddle\Exceptions;
use RuntimeException;
class PaddleException extends RuntimeException
{
public function __construct(string $message, private readonly ?int $status = null, private readonly array $context = [])
{
parent::__construct($message, $status ?? 0);
}
public function status(): ?int
{
return $this->status;
}
public function context(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace App\Services\Paddle;
use App\Models\Package;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PaddleCatalogService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* @return array<string, mixed>
*/
public function fetchProduct(string $productId): array
{
return $this->extractEntity($this->client->get("/products/{$productId}"));
}
/**
* @return array<string, mixed>
*/
public function fetchPrice(string $priceId): array
{
return $this->extractEntity($this->client->get("/prices/{$priceId}"));
}
/**
* @return array<string, mixed>
*/
public function createProduct(Package $package, array $overrides = []): array
{
$payload = $this->buildProductPayload($package, $overrides);
return $this->extractEntity($this->client->post('/products', $payload));
}
/**
* @return array<string, mixed>
*/
public function updateProduct(string $productId, Package $package, array $overrides = []): array
{
$payload = $this->buildProductPayload($package, $overrides);
return $this->extractEntity($this->client->patch("/products/{$productId}", $payload));
}
/**
* @return array<string, mixed>
*/
public function createPrice(Package $package, string $productId, array $overrides = []): array
{
$payload = $this->buildPricePayload($package, $productId, $overrides);
return $this->extractEntity($this->client->post('/prices', $payload));
}
/**
* @return array<string, mixed>
*/
public function updatePrice(string $priceId, Package $package, array $overrides = []): array
{
$payload = $this->buildPricePayload($package, $overrides['product_id'] ?? $package->paddle_product_id, $overrides);
return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload));
}
/**
* @return array<string, mixed>
*/
public function buildProductPayload(Package $package, array $overrides = []): array
{
$payload = array_merge([
'name' => $this->resolveName($package, $overrides),
'description' => $this->resolveDescription($package, $overrides),
'tax_category' => $overrides['tax_category'] ?? 'standard',
'type' => $overrides['type'] ?? 'standard',
'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []),
], Arr::except($overrides, ['tax_category', 'type', 'custom_data']));
return $this->cleanPayload($payload);
}
/**
* @return array<string, mixed>
*/
public function buildPricePayload(Package $package, string $productId, array $overrides = []): array
{
$unitPrice = $overrides['unit_price'] ?? [
'amount' => (string) $this->priceToMinorUnits($package->price),
'currency_code' => Str::upper((string) ($package->currency ?? 'EUR')),
];
$payload = array_merge([
'product_id' => $productId,
'description' => $this->resolvePriceDescription($package, $overrides),
'unit_price' => $unitPrice,
'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []),
], Arr::except($overrides, ['unit_price', 'description', 'custom_data']));
return $this->cleanPayload($payload);
}
/**
* @param array<string, mixed> $response
* @return array<string, mixed>
*/
protected function extractEntity(array $response): array
{
return Arr::get($response, 'data', $response);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function cleanPayload(array $payload): array
{
$filtered = collect($payload)
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
->all();
if (array_key_exists('custom_data', $filtered)) {
$filtered['custom_data'] = collect($filtered['custom_data'])
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
->all();
}
return $filtered;
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
protected function buildCustomData(Package $package, array $extra = []): array
{
$base = [
'fotospiel_package_id' => (string) $package->id,
'slug' => $package->slug,
'type' => $package->type,
'features' => $package->features,
'limits' => array_filter([
'max_photos' => $package->max_photos,
'max_guests' => $package->max_guests,
'gallery_days' => $package->gallery_days,
'max_tasks' => $package->max_tasks,
'max_events_per_year' => $package->max_events_per_year,
], static fn ($value) => $value !== null),
'translations' => array_filter([
'name' => $package->name_translations,
'description' => $package->description_translations,
], static fn ($value) => ! empty($value)),
];
return array_merge($base, $extra);
}
protected function resolveName(Package $package, array $overrides): string
{
if (isset($overrides['name']) && is_string($overrides['name'])) {
return $overrides['name'];
}
if (! empty($package->name)) {
return $package->name;
}
$translations = $package->name_translations ?? [];
return $translations['en'] ?? $translations['de'] ?? $package->slug;
}
protected function resolveDescription(Package $package, array $overrides): string
{
if (array_key_exists('description', $overrides)) {
$value = is_string($overrides['description']) ? trim($overrides['description']) : null;
if ($value !== null && $value !== '') {
return $value;
}
}
if (! empty($package->description)) {
return strip_tags((string) $package->description);
}
$translations = $package->description_translations ?? [];
$fallback = $translations['en'] ?? $translations['de'] ?? null;
if ($fallback !== null) {
$fallback = trim(strip_tags((string) $fallback));
if ($fallback !== '') {
return $fallback;
}
}
return sprintf('Fotospiel package %s', $package->slug ?? $package->id);
}
/**
* @param array<string, mixed> $overrides
*/
protected function resolvePriceDescription(Package $package, array $overrides): string
{
if (array_key_exists('description', $overrides)) {
$value = is_string($overrides['description']) ? trim($overrides['description']) : null;
if ($value !== null && $value !== '') {
return $value;
}
}
if (! empty($package->description)) {
return strip_tags((string) $package->description);
}
$translations = $package->description_translations ?? [];
$fallback = $translations['en'] ?? $translations['de'] ?? null;
if ($fallback !== null) {
$fallback = trim(strip_tags((string) $fallback));
if ($fallback !== '') {
return $fallback;
}
}
$name = $package->name ?? $package->getNameForLocale('en');
if ($name) {
return sprintf('%s package', trim($name));
}
return sprintf('Package %s', $package->slug ?? $package->id);
}
protected function priceToMinorUnits(mixed $price): int
{
$value = is_string($price) ? (float) $price : (float) ($price ?? 0);
return (int) round($value * 100);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Services\Paddle;
use App\Models\Package;
use App\Models\Tenant;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class PaddleCheckoutService
{
public function __construct(
private readonly PaddleClient $client,
private readonly PaddleCustomerService $customers,
) {}
/**
* @param array{success_url?: string|null, return_url?: string|null} $options
*/
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
{
$customerId = $this->customers->ensureCustomerId($tenant);
$successUrl = $options['success_url'] ?? route('marketing.success', ['packageId' => $package->id]);
$returnUrl = $options['return_url'] ?? route('packages', ['highlight' => $package->slug]);
$metadata = $this->buildMetadata($tenant, $package, $options['metadata'] ?? []);
$payload = [
'customer_id' => $customerId,
'items' => [
[
'price_id' => $package->paddle_price_id,
'quantity' => 1,
],
],
'metadata' => $metadata,
'success_url' => $successUrl,
'cancel_url' => $returnUrl,
];
if ($tenant->contact_email) {
$payload['customer_email'] = $tenant->contact_email;
}
$response = $this->client->post('/checkout/links', $payload);
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url');
if (! $checkoutUrl) {
Log::warning('Paddle checkout response missing url', ['response' => $response]);
}
return [
'checkout_url' => $checkoutUrl,
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
];
}
/**
* @param array<string, mixed> $extra
* @return array<string, string>
*/
protected function buildMetadata(Tenant $tenant, Package $package, array $extra = []): array
{
$metadata = [
'tenant_id' => (string) $tenant->id,
'package_id' => (string) $package->id,
];
foreach ($extra as $key => $value) {
if (! is_string($key)) {
continue;
}
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
$metadata[$key] = (string) $value;
}
}
return $metadata;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Services\Paddle;
use App\Services\Paddle\Exceptions\PaddleException;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PaddleClient
{
public function __construct(
private readonly HttpFactory $http,
) {}
public function get(string $endpoint, array $query = []): array
{
return $this->send('GET', $endpoint, ['query' => $query]);
}
public function post(string $endpoint, array $payload = []): array
{
return $this->send('POST', $endpoint, ['json' => $payload]);
}
public function patch(string $endpoint, array $payload = []): array
{
return $this->send('PATCH', $endpoint, ['json' => $payload]);
}
public function delete(string $endpoint, array $payload = []): array
{
return $this->send('DELETE', $endpoint, ['json' => $payload]);
}
protected function send(string $method, string $endpoint, array $options = []): array
{
$request = $this->preparedRequest();
try {
$response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options);
} catch (RequestException $exception) {
throw new PaddleException($exception->getMessage(), $exception->response?->status(), $exception->response?->json() ?? []);
}
if ($response->failed()) {
$body = $response->json() ?? [];
$message = Arr::get($body, 'error.message')
?? Arr::get($body, 'message')
?? sprintf('Paddle request failed with status %s', $response->status());
throw new PaddleException($message, $response->status(), $body);
}
return $response->json() ?? [];
}
protected function preparedRequest(): PendingRequest
{
$apiKey = config('paddle.api_key');
if (! $apiKey) {
throw new PaddleException('Paddle API key is not configured.');
}
$baseUrl = rtrim((string) config('paddle.base_url'), '/');
$environment = (string) config('paddle.environment', 'production');
$headers = [
'User-Agent' => sprintf('FotospielApp/%s PaddleClient', app()->version()),
'Paddle-Environment' => Str::lower($environment) === 'sandbox' ? 'sandbox' : 'production',
];
return $this->http
->baseUrl($baseUrl)
->withHeaders($headers)
->withToken($apiKey)
->acceptJson()
->asJson();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Services\Paddle;
use App\Models\Tenant;
use App\Services\Paddle\Exceptions\PaddleException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class PaddleCustomerService
{
public function __construct(private readonly PaddleClient $client) {}
public function ensureCustomerId(Tenant $tenant): string
{
if ($tenant->paddle_customer_id) {
return $tenant->paddle_customer_id;
}
$payload = [
'email' => $tenant->contact_email ?: ($tenant->user?->email ?? null),
'name' => $tenant->name,
];
if (! $payload['email']) {
throw new PaddleException('Tenant email address required to create Paddle customer.');
}
$response = $this->client->post('/customers', $payload);
$customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $customerId) {
Log::error('Paddle customer creation returned no id', ['tenant' => $tenant->id, 'response' => $response]);
throw new PaddleException('Failed to create Paddle customer.');
}
$tenant->forceFill(['paddle_customer_id' => $customerId])->save();
return $customerId;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Services\Paddle;
use Illuminate\Support\Arr;
class PaddleSubscriptionService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* Retrieve a subscription record directly from Paddle.
*
* @return array<string, mixed>
*/
public function retrieve(string $subscriptionId): array
{
$response = $this->client->get("/subscriptions/{$subscriptionId}");
return is_array($response) ? $response : [];
}
/**
* Convenience helper to extract metadata from the subscription response.
*
* @param array<string, mixed> $subscription
* @return array<string, mixed>
*/
public function metadata(array $subscription): array
{
return Arr::get($subscription, 'data.metadata', []);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services\Paddle;
use Illuminate\Support\Arr;
class PaddleTransactionService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
public function listForCustomer(string $customerId, array $query = []): array
{
$payload = array_filter(array_merge([
'customer_id' => $customerId,
'order_by' => '-created_at',
], $query), static fn ($value) => $value !== null && $value !== '');
$response = $this->client->get('/transactions', $payload);
$transactions = Arr::get($response, 'data', []);
$meta = Arr::get($response, 'meta.pagination', []);
if (! is_array($transactions)) {
$transactions = [];
}
return [
'data' => array_map([$this, 'mapTransaction'], $transactions),
'meta' => $this->mapPagination($meta),
];
}
/**
* @param array<string, mixed> $transaction
* @return array<string, mixed>
*/
protected function mapTransaction(array $transaction): array
{
$totals = Arr::get($transaction, 'totals', []);
return [
'id' => $transaction['id'] ?? null,
'status' => $transaction['status'] ?? null,
'amount' => $this->resolveAmount($transaction, $totals),
'currency' => $transaction['currency_code'] ?? Arr::get($transaction, 'currency') ?? 'EUR',
'origin' => $transaction['origin'] ?? null,
'checkout_id' => $transaction['checkout_id'] ?? Arr::get($transaction, 'details.checkout_id'),
'created_at' => $transaction['created_at'] ?? null,
'updated_at' => $transaction['updated_at'] ?? null,
'receipt_url' => Arr::get($transaction, 'invoice_url') ?? Arr::get($transaction, 'receipt_url'),
'tax' => Arr::get($totals, 'tax_total') ?? null,
'grand_total' => Arr::get($totals, 'grand_total') ?? null,
];
}
/**
* @param array<string, mixed> $transaction
* @param array<string, mixed>|null $totals
*/
protected function resolveAmount(array $transaction, $totals): ?float
{
$amount = Arr::get($totals ?? [], 'subtotal') ?? Arr::get($totals ?? [], 'grand_total');
if ($amount !== null) {
return (float) $amount;
}
$raw = $transaction['amount'] ?? null;
if ($raw === null) {
return null;
}
return (float) $raw;
}
/**
* @param array<string, mixed> $pagination
* @return array<string, mixed>
*/
protected function mapPagination(array $pagination): array
{
return [
'next' => $pagination['next'] ?? null,
'previous' => $pagination['previous'] ?? null,
'has_more' => (bool) ($pagination['has_more'] ?? false),
];
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Services\PayPal;
use PaypalServerSdkLib\PaypalServerSdkClient;
use PaypalServerSdkLib\PaypalServerSdkClientBuilder;
use PaypalServerSdkLib\Authentication\ClientCredentialsAuthCredentialsBuilder;
use PaypalServerSdkLib\Environment;
class PaypalClientFactory
{
public function make(?bool $sandbox = null, ?string $clientId = null, ?string $clientSecret = null): PaypalServerSdkClient
{
$clientId = $clientId ?? config('services.paypal.client_id');
$clientSecret = $clientSecret ?? config('services.paypal.secret');
$isSandbox = $sandbox ?? config('services.paypal.sandbox', true);
$environment = $isSandbox ? Environment::SANDBOX : Environment::PRODUCTION;
return PaypalServerSdkClientBuilder::init()
->clientCredentialsAuthCredentials(
ClientCredentialsAuthCredentialsBuilder::init($clientId, $clientSecret)
)
->environment($environment)
->build();
}
}

View File

@@ -43,6 +43,8 @@ trait PresentsPackages
'slug' => $package->slug, 'slug' => $package->slug,
'type' => $package->type, 'type' => $package->type,
'price' => $package->price, 'price' => $package->price,
'paddle_product_id' => $package->paddle_product_id,
'paddle_price_id' => $package->paddle_price_id,
'description' => $description, 'description' => $description,
'description_breakdown' => $table, 'description_breakdown' => $table,
'gallery_duration_label' => $galleryDuration, 'gallery_duration_label' => $galleryDuration,
@@ -144,6 +146,7 @@ trait PresentsPackages
foreach ($features as $key => $value) { foreach ($features as $key => $value) {
if (is_string($value)) { if (is_string($value)) {
$list[] = $value; $list[] = $value;
continue; continue;
} }

View File

@@ -18,7 +18,6 @@
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9", "laravel/wayfinder": "^0.1.9",
"league/commonmark": "^2.7", "league/commonmark": "^2.7",
"paypal/paypal-server-sdk": "^1.1",
"simplesoftwareio/simple-qrcode": "^4.2", "simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-translatable": "^6.11", "spatie/laravel-translatable": "^6.11",
"staudenmeir/belongs-to-through": "^2.17", "staudenmeir/belongs-to-through": "^2.17",

318
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c4ce377acba80c944149cab30605d24c", "content-hash": "5409eee4f26e2827449d85cf6b40209d",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -72,222 +72,6 @@
}, },
"time": "2025-07-30T15:45:57+00:00" "time": "2025-07-30T15:45:57+00:00"
}, },
{
"name": "apimatic/core",
"version": "0.3.14",
"source": {
"type": "git",
"url": "https://github.com/apimatic/core-lib-php.git",
"reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/apimatic/core-lib-php/zipball/c3eaad6cf0c00b793ce6d9bee8b87176247da582",
"reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582",
"shasum": ""
},
"require": {
"apimatic/core-interfaces": "~0.1.5",
"apimatic/jsonmapper": "^3.1.1",
"ext-curl": "*",
"ext-dom": "*",
"ext-json": "*",
"ext-libxml": "*",
"php": "^7.2 || ^8.0",
"php-jsonpointer/php-jsonpointer": "^3.0.2",
"psr/log": "^1.1.4 || ^2.0.0 || ^3.0.0"
},
"require-dev": {
"phan/phan": "5.4.5",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Core\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Core logic and the utilities for the Apimatic's PHP SDK",
"homepage": "https://github.com/apimatic/core-lib-php",
"keywords": [
"apimatic",
"core",
"corelib",
"php"
],
"support": {
"issues": "https://github.com/apimatic/core-lib-php/issues",
"source": "https://github.com/apimatic/core-lib-php/tree/0.3.14"
},
"time": "2025-02-27T06:03:30+00:00"
},
{
"name": "apimatic/core-interfaces",
"version": "0.1.5",
"source": {
"type": "git",
"url": "https://github.com/apimatic/core-interfaces-php.git",
"reference": "b4f1bffc8be79584836f70af33c65e097eec155c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/apimatic/core-interfaces-php/zipball/b4f1bffc8be79584836f70af33c65e097eec155c",
"reference": "b4f1bffc8be79584836f70af33c65e097eec155c",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"CoreInterfaces\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Definition of the behavior of apimatic/core, apimatic/unirest-php and Apimatic's PHP SDK",
"homepage": "https://github.com/apimatic/core-interfaces-php",
"keywords": [
"apimatic",
"core",
"corelib",
"interface",
"php",
"unirest"
],
"support": {
"issues": "https://github.com/apimatic/core-interfaces-php/issues",
"source": "https://github.com/apimatic/core-interfaces-php/tree/0.1.5"
},
"time": "2024-05-09T06:32:07+00:00"
},
{
"name": "apimatic/jsonmapper",
"version": "3.1.6",
"source": {
"type": "git",
"url": "https://github.com/apimatic/jsonmapper.git",
"reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/apimatic/jsonmapper/zipball/c6cc21bd56bfe5d5822bbd08f514be465c0b24e7",
"reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^5.6 || ^7.0 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"squizlabs/php_codesniffer": "^3.0.0"
},
"type": "library",
"autoload": {
"psr-4": {
"apimatic\\jsonmapper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"OSL-3.0"
],
"authors": [
{
"name": "Christian Weiske",
"email": "christian.weiske@netresearch.de",
"homepage": "http://www.netresearch.de/",
"role": "Developer"
},
{
"name": "Mehdi Jaffery",
"email": "mehdi.jaffery@apimatic.io",
"homepage": "http://apimatic.io/",
"role": "Developer"
}
],
"description": "Map nested JSON structures onto PHP classes",
"support": {
"email": "mehdi.jaffery@apimatic.io",
"issues": "https://github.com/apimatic/jsonmapper/issues",
"source": "https://github.com/apimatic/jsonmapper/tree/3.1.6"
},
"time": "2024-11-28T09:15:32+00:00"
},
{
"name": "apimatic/unirest-php",
"version": "4.0.7",
"source": {
"type": "git",
"url": "https://github.com/apimatic/unirest-php.git",
"reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/apimatic/unirest-php/zipball/bdfd5f27c105772682c88ed671683f1bd93f4a3c",
"reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c",
"shasum": ""
},
"require": {
"apimatic/core-interfaces": "^0.1.0",
"ext-curl": "*",
"ext-json": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phan/phan": "5.4.2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Unirest\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mashape",
"email": "opensource@mashape.com",
"homepage": "https://www.mashape.com",
"role": "Developer"
},
{
"name": "APIMATIC",
"email": "opensource@apimatic.io",
"homepage": "https://www.apimatic.io",
"role": "Developer"
}
],
"description": "Unirest PHP",
"homepage": "https://github.com/apimatic/unirest-php",
"keywords": [
"client",
"curl",
"http",
"https",
"rest"
],
"support": {
"email": "opensource@apimatic.io",
"issues": "https://github.com/apimatic/unirest-php/issues",
"source": "https://github.com/apimatic/unirest-php/tree/4.0.7"
},
"time": "2025-06-17T09:09:48+00:00"
},
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
"version": "2.0.8", "version": "2.0.8",
@@ -4974,50 +4758,6 @@
}, },
"time": "2020-10-15T08:29:30+00:00" "time": "2020-10-15T08:29:30+00:00"
}, },
{
"name": "paypal/paypal-server-sdk",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/paypal/PayPal-PHP-Server-SDK.git",
"reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paypal/PayPal-PHP-Server-SDK/zipball/3964c1732b1815fa8cf8aee37069ccc4e95d9572",
"reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572",
"shasum": ""
},
"require": {
"apimatic/core": "~0.3.13",
"apimatic/core-interfaces": "~0.1.5",
"apimatic/unirest-php": "^4.0.6",
"ext-curl": "*",
"ext-json": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phan/phan": "5.4.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"autoload": {
"psr-4": {
"PaypalServerSdkLib\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PayPal's SDK for interacting with the REST APIs",
"homepage": "https://github.com/paypal/PayPal-PHP-Server-SDK",
"support": {
"issues": "https://github.com/paypal/PayPal-PHP-Server-SDK/issues",
"source": "https://github.com/paypal/PayPal-PHP-Server-SDK/tree/1.1.0"
},
"time": "2025-05-27T17:46:31+00:00"
},
{ {
"name": "phenx/php-font-lib", "name": "phenx/php-font-lib",
"version": "0.5.6", "version": "0.5.6",
@@ -5108,62 +4848,6 @@
}, },
"time": "2022-03-07T12:52:04+00:00" "time": "2022-03-07T12:52:04+00:00"
}, },
{
"name": "php-jsonpointer/php-jsonpointer",
"version": "v3.0.2",
"source": {
"type": "git",
"url": "https://github.com/raphaelstolt/php-jsonpointer.git",
"reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/raphaelstolt/php-jsonpointer/zipball/4428f86c6f23846e9faa5a420c4ef14e485b3afb",
"reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb",
"shasum": ""
},
"require": {
"php": ">=5.4"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^1.11",
"phpunit/phpunit": "4.6.*"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-0": {
"Rs\\Json": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Stolt",
"email": "raphael.stolt@gmail.com",
"homepage": "http://raphaelstolt.blogspot.com/"
}
],
"description": "Implementation of JSON Pointer (http://tools.ietf.org/html/rfc6901)",
"homepage": "https://github.com/raphaelstolt/php-jsonpointer",
"keywords": [
"json",
"json pointer",
"json traversal"
],
"support": {
"issues": "https://github.com/raphaelstolt/php-jsonpointer/issues",
"source": "https://github.com/raphaelstolt/php-jsonpointer/tree/master"
},
"time": "2016-08-29T08:51:01+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.4", "version": "1.9.4",

33
config/paddle.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
$sandbox = filter_var(env('PADDLE_SANDBOX', false), FILTER_VALIDATE_BOOLEAN);
$environment = env('PADDLE_ENVIRONMENT', $sandbox ? 'sandbox' : 'production');
$apiKey = env('PADDLE_API_KEY') ?: ($sandbox ? env('PADDLE_SANDBOX_API_KEY') : null);
$clientToken = env('PADDLE_CLIENT_TOKEN') ?: env('PADDLE_CLIENT_ID') ?: ($sandbox ? (env('PADDLE_SANDBOX_CLIENT_TOKEN') ?: env('PADDLE_SANDBOX_CLIENT_ID')) : null);
$webhookSecret = env('PADDLE_WEBHOOK_SECRET') ?: ($sandbox ? env('PADDLE_SANDBOX_WEBHOOK_SECRET') : null);
$publicKey = env('PADDLE_PUBLIC_KEY') ?: ($sandbox ? env('PADDLE_SANDBOX_PUBLIC_KEY') : null);
$baseUrl = env('PADDLE_BASE_URL');
if (! $baseUrl) {
$baseUrl = $sandbox ? 'https://sandbox-api.paddle.com' : 'https://api.paddle.com';
}
$consoleUrl = env('PADDLE_CONSOLE_URL');
if (! $consoleUrl) {
$consoleUrl = $sandbox ? 'https://sandbox-dashboard.paddle.com' : 'https://dashboard.paddle.com';
}
return [
'api_key' => $apiKey,
'client_token' => $clientToken,
'environment' => $environment,
'base_url' => $baseUrl,
'console_url' => $consoleUrl,
'webhook_secret' => $webhookSecret,
'public_key' => $publicKey,
];

View File

@@ -43,6 +43,13 @@ return [
'sandbox' => env('PAYPAL_SANDBOX', true), 'sandbox' => env('PAYPAL_SANDBOX', true),
], ],
'paddle' => [
'api_key' => env('PADDLE_API_KEY'),
'client_id' => env('PADDLE_CLIENT_ID'),
'sandbox' => env('PADDLE_SANDBOX', false),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
],
'google' => [ 'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'), 'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'), 'client_secret' => env('GOOGLE_CLIENT_SECRET'),

View File

@@ -13,6 +13,7 @@ class PackageFactory extends Factory
public function definition(): array public function definition(): array
{ {
$name = $this->faker->word(); $name = $this->faker->word();
return [ return [
'name' => $name, 'name' => $name,
'slug' => Str::slug($name.'-'.uniqid()), 'slug' => Str::slug($name.'-'.uniqid()),
@@ -29,6 +30,9 @@ class PackageFactory extends Factory
'advanced_analytics' => $this->faker->boolean(), 'advanced_analytics' => $this->faker->boolean(),
]), ]),
'type' => $this->faker->randomElement(['endcustomer', 'reseller']), 'type' => $this->faker->randomElement(['endcustomer', 'reseller']),
'paddle_sync_status' => null,
'paddle_synced_at' => null,
'paddle_snapshot' => null,
]; ];
} }

View File

@@ -16,6 +16,7 @@ class PackagePurchaseFactory extends Factory
return [ return [
'tenant_id' => Tenant::factory(), 'tenant_id' => Tenant::factory(),
'package_id' => Package::factory(), 'package_id' => Package::factory(),
'provider' => 'manual',
'provider_id' => $this->faker->uuid(), 'provider_id' => $this->faker->uuid(),
'price' => $this->faker->randomFloat(2, 0, 500), 'price' => $this->faker->randomFloat(2, 0, 500),
'purchased_at' => now(), 'purchased_at' => now(),

View File

@@ -34,8 +34,8 @@ return new class extends Migration
$table->string('stripe_payment_intent_id')->nullable(); $table->string('stripe_payment_intent_id')->nullable();
$table->string('stripe_customer_id')->nullable(); $table->string('stripe_customer_id')->nullable();
$table->string('stripe_subscription_id')->nullable(); $table->string('stripe_subscription_id')->nullable();
$table->string('paypal_order_id')->nullable(); $table->string('paddle_checkout_id')->nullable();
$table->string('paypal_subscription_id')->nullable(); $table->string('paddle_transaction_id')->nullable();
$table->json('provider_metadata')->nullable(); $table->json('provider_metadata')->nullable();
$table->string('locale', 5)->nullable(); $table->string('locale', 5)->nullable();
@@ -47,7 +47,8 @@ return new class extends Migration
$table->softDeletes(); $table->softDeletes();
$table->unique('stripe_payment_intent_id'); $table->unique('stripe_payment_intent_id');
$table->unique('paypal_order_id'); $table->unique('paddle_checkout_id');
$table->unique('paddle_transaction_id');
$table->index(['provider', 'status']); $table->index(['provider', 'status']);
$table->index('expires_at'); $table->index('expires_at');
}); });

View File

@@ -0,0 +1,85 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('packages', 'paddle_product_id')) {
Schema::table('packages', function (Blueprint $table) {
$table->string('paddle_product_id')->nullable()->after('price');
$table->string('paddle_price_id')->nullable()->after('paddle_product_id');
$table->index('paddle_product_id');
$table->index('paddle_price_id');
});
}
if (! Schema::hasColumn('tenants', 'paddle_customer_id')) {
Schema::table('tenants', function (Blueprint $table) {
$table->string('paddle_customer_id')->nullable()->after('subscription_status');
$table->index('paddle_customer_id');
});
}
if (! Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) {
Schema::table('tenant_packages', function (Blueprint $table) {
$table->string('paddle_subscription_id')->nullable()->after('package_id');
$table->index('paddle_subscription_id');
});
}
if (! Schema::hasColumn('package_purchases', 'provider')) {
Schema::table('package_purchases', function (Blueprint $table) {
$table->string('provider')->nullable()->after('package_id');
$table->index('provider');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('packages', 'paddle_price_id')) {
Schema::table('packages', function (Blueprint $table) {
$table->dropIndex('packages_paddle_price_id_index');
$table->dropColumn('paddle_price_id');
});
}
if (Schema::hasColumn('packages', 'paddle_product_id')) {
Schema::table('packages', function (Blueprint $table) {
$table->dropIndex('packages_paddle_product_id_index');
$table->dropColumn('paddle_product_id');
});
}
if (Schema::hasColumn('tenants', 'paddle_customer_id')) {
Schema::table('tenants', function (Blueprint $table) {
$table->dropIndex('tenants_paddle_customer_id_index');
$table->dropColumn('paddle_customer_id');
});
}
if (Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) {
Schema::table('tenant_packages', function (Blueprint $table) {
$table->dropIndex('tenant_packages_paddle_subscription_id_index');
$table->dropColumn('paddle_subscription_id');
});
}
if (Schema::hasColumn('package_purchases', 'provider')) {
Schema::table('package_purchases', function (Blueprint $table) {
$table->dropIndex('package_purchases_provider_index');
$table->dropColumn('provider');
});
}
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('packages', function (Blueprint $table) {
$table->string('paddle_sync_status', 50)
->nullable()
->after('paddle_price_id');
$table->timestamp('paddle_synced_at')
->nullable()
->after('paddle_sync_status');
$table->json('paddle_snapshot')
->nullable()
->after('paddle_synced_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('packages', function (Blueprint $table) {
$table->dropColumn(['paddle_sync_status', 'paddle_synced_at', 'paddle_snapshot']);
});
}
};

View File

@@ -149,6 +149,7 @@ class DemoLifecycleSeeder extends Seeder
$purchase = PackagePurchase::create([ $purchase = PackagePurchase::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $premium->id, 'package_id' => $premium->id,
'provider' => 'stripe',
'provider_id' => 'stripe_demo_pi', 'provider_id' => 'stripe_demo_pi',
'price' => $premium->price, 'price' => $premium->price,
'type' => 'endcustomer_event', 'type' => 'endcustomer_event',
@@ -188,6 +189,7 @@ class DemoLifecycleSeeder extends Seeder
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'event_id' => $draftEvent->id, 'event_id' => $draftEvent->id,
'package_id' => $standard->id, 'package_id' => $standard->id,
'provider' => 'paypal',
'provider_id' => 'paypal_demo_capture', 'provider_id' => 'paypal_demo_capture',
'price' => $standard->price, 'price' => $standard->price,
'type' => 'endcustomer_event', 'type' => 'endcustomer_event',
@@ -223,6 +225,7 @@ class DemoLifecycleSeeder extends Seeder
PackagePurchase::create([ PackagePurchase::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $reseller->id, 'package_id' => $reseller->id,
'provider' => 'stripe',
'provider_id' => 'stripe_demo_subscription', 'provider_id' => 'stripe_demo_subscription',
'price' => $reseller->price, 'price' => $reseller->price,
'type' => 'reseller_subscription', 'type' => 'reseller_subscription',
@@ -253,6 +256,7 @@ class DemoLifecycleSeeder extends Seeder
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'event_id' => $event->id, 'event_id' => $event->id,
'package_id' => $standard->id, 'package_id' => $standard->id,
'provider' => 'manual',
'provider_id' => 'reseller_allowance', 'provider_id' => 'reseller_allowance',
'price' => 0, 'price' => 0,
'type' => 'endcustomer_event', 'type' => 'endcustomer_event',

View File

@@ -10,7 +10,7 @@
### Backend (MarketingRegisterController.php) ### Backend (MarketingRegisterController.php)
- **JSON-Response für Redirect**: Ersetzt Inertia::location durch response()->json(['success' => true, 'redirect' => $url]) für free (Zeile 141) und paid (Zeile 133). Kompatibel mit Inertia onSuccess (page.props.success/redirect prüfen). - **JSON-Response für Redirect**: Ersetzt Inertia::location durch response()->json(['success' => true, 'redirect' => $url]) für free (Zeile 141) und paid (Zeile 133). Kompatibel mit Inertia onSuccess (page.props.success/redirect prüfen).
- **Tenant Name Fix**: 'name' => $request->first_name . ' ' . $request->last_name (Zeile 71); slug entsprechend angepasst. - **Tenant Name Fix**: 'name' => $request->first_name . ' ' . $request->last_name (Zeile 71); slug entsprechend angepasst.
- **Role-Logic**: 'role' => 'user' in User::create (Zeile 66); für free: Update zu 'tenant_admin' nach TenantPackage::create (Zeile 129), Re-Login (Zeile 130). Für paid: Kein Upgrade bis Webhook (Stripe/PayPal). - **Role-Logic**: 'role' => 'user' in User::create (Zeile 66); für free: Update zu 'tenant_admin' nach TenantPackage::create (Zeile 129), Re-Login (Zeile 130). Für paid: Kein Upgrade bis Webhook (Paddle).
- **Return-Type**: store() zu JsonResponse (Zeile 44); use JsonResponse hinzugefügt (Zeile 22). - **Return-Type**: store() zu JsonResponse (Zeile 44); use JsonResponse hinzugefügt (Zeile 22).
### Frontend (Register.tsx) ### Frontend (Register.tsx)
@@ -30,7 +30,7 @@
- **Linter/TS**: Keine Errors; Intelephense fixed durch JsonResponse use und as string cast. - **Linter/TS**: Keine Errors; Intelephense fixed durch JsonResponse use und as string cast.
## PRP-Update (docs/prp/13-backend-authentication.md) ## PRP-Update (docs/prp/13-backend-authentication.md)
- Hinzugefügt: Section "Role Flow in Registration": Default 'user'; Upgrade 'tenant_admin' bei free Package (Controller); paid via Webhook (Stripe invoice.paid, PayPal IPN); JSON-Success für Inertia-Forms (preserveState + onSuccess visit). - Hinzugefügt: Section "Role Flow in Registration": Default 'user'; Upgrade 'tenant_admin' bei free Package (Controller); paid via Webhook (Stripe invoice.paid, Paddle IPN); JSON-Success für Inertia-Forms (preserveState + onSuccess visit).
## Best Practices ## Best Practices
- Inertia-Forms: Bei preserveState JSON-Response für custom Redirects verwenden, statt location() (vermeidet State-Ignorieren). - Inertia-Forms: Bei preserveState JSON-Response für custom Redirects verwenden, statt location() (vermeidet State-Ignorieren).

View File

@@ -8,7 +8,7 @@
### Wizard Foundations ### Wizard Foundations
- [x] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes (see `resources/js/pages/marketing/checkout/steps/PackageStep.tsx`). - [x] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes (see `resources/js/pages/marketing/checkout/steps/PackageStep.tsx`).
- [x] Redesign the payment step: Stripe and PayPal happy path, failure, retry; add subscription handling for reseller plans. *(Stripe intent lifecycle + PayPal subscription flow now share status alerts, retry logic, and plan gating in `PaymentStep.tsx`.)* - [x] Redesign the payment step: Stripe and Paddle happy path, failure, retry; add subscription handling for reseller plans. *(Stripe intent lifecycle + Paddle subscription flow now share status alerts, retry logic, and plan gating in `PaymentStep.tsx`.)*
- [x] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`. *(Handled via `ConfirmationStep.tsx` + wizard callbacks redirecting to `/settings/profile` and `/event-admin`.)* - [x] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`. *(Handled via `ConfirmationStep.tsx` + wizard callbacks redirecting to `/settings/profile` and `/event-admin`.)*
### Authentication & Profile Data ### Authentication & Profile Data
@@ -20,7 +20,7 @@
- [x] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration. *(Legacy components removed; new wizard replaces them.)* - [x] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration. *(Legacy components removed; new wizard replaces them.)*
### Quality & Rollout ### Quality & Rollout
- [x] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints. *(Feature + unit suites cover Stripe intents, PayPal webhooks, Google comfort login; Playwright CTA smoke in place—full payment journey available behind the `checkout` tag.)* - [x] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints. *(Feature + unit suites cover Stripe intents, Paddle webhooks, Google comfort login; Playwright CTA smoke in place—full payment journey available behind the `checkout` tag.)*
- [x] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard. - [x] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard.
## Notes ## Notes
@@ -34,6 +34,6 @@
- [x] Define provider-agnostic payment state machine (intent creation, approval, capture, failure). See docs/prp/marketing-checkout-payment-architecture.md. - [x] Define provider-agnostic payment state machine (intent creation, approval, capture, failure). See docs/prp/marketing-checkout-payment-architecture.md.
- [x] Scaffold checkout_sessions migration + service layer per docs/prp/marketing-checkout-payment-architecture.md. - [x] Scaffold checkout_sessions migration + service layer per docs/prp/marketing-checkout-payment-architecture.md.
- [x] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session. *(Covered by `CheckoutController::createPaymentIntent`.)* - [x] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session. *(Covered by `CheckoutController::createPaymentIntent`.)*
- [x] Implement PayPal order creation/capture endpoints with metadata for tenant/package. *(Routes now exposed in `routes/web.php`; controller derives tenant context for authenticated users.)* - [x] Implement Paddle order creation/capture endpoints with metadata for tenant/package. *(Routes now exposed in `routes/web.php`; controller derives tenant context for authenticated users.)*
- [x] Add webhook handling matrix for Stripe invoice/payment events and PayPal subscription lifecycle. - [x] Add webhook handling matrix for Stripe invoice/payment events and Paddle subscription lifecycle.
- [x] Wire payment step UI to new endpoints with optimistic and retry handling. *(See `PaymentStep.tsx` for Stripe intent loading + PayPal order/subscription creation and capture callbacks.)* - [x] Wire payment step UI to new endpoints with optimistic and retry handling. *(See `PaymentStep.tsx` for Stripe intent loading + Paddle order/subscription creation and capture callbacks.)*

View File

@@ -1,11 +1,11 @@
# PayPal SDK Migration to v1 Server SDK # Paddle SDK Migration to v1 Server SDK
## Summary ## Summary
Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-sdk ^1.0+` in PayPalController.php. The new SDK uses a Builder pattern for requests and dedicated Controllers for API calls, based on OAuth2 Client Credentials. Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-sdk ^1.0+` in PaddleController.php. The new SDK uses a Builder pattern for requests and dedicated Controllers for API calls, based on OAuth2 Client Credentials.
## Changes ## Changes
- **Composer**: Removed `paypal/paypal-checkout-sdk`; retained/updated `paypal/paypal-server-sdk`. - **Composer**: Removed `paypal/paypal-checkout-sdk`; retained/updated `paypal/paypal-server-sdk`.
- **Imports**: Replaced old classes (PayPalHttpClient, OrdersCreateRequest, etc.) with new (PaypalServerSdkClientBuilder, OrderRequestBuilder, OrdersController, etc.). - **Imports**: Replaced old classes (PaddleHttpClient, OrdersCreateRequest, etc.) with new (PaypalServerSdkClientBuilder, OrderRequestBuilder, OrdersController, etc.).
- **Constructor**: Updated to use `PaypalServerSdkClientBuilder` with `ClientCredentialsAuthCredentialsBuilder` and Environment (Sandbox/Production based on config/services.php). - **Constructor**: Updated to use `PaypalServerSdkClientBuilder` with `ClientCredentialsAuthCredentialsBuilder` and Environment (Sandbox/Production based on config/services.php).
- **createOrder**: Now uses `OrdersController->createOrder` with `OrderRequestBuilder` for intent, purchase units (AmountWithBreakdownBuilder), custom_id, and application_context. - **createOrder**: Now uses `OrdersController->createOrder` with `OrderRequestBuilder` for intent, purchase units (AmountWithBreakdownBuilder), custom_id, and application_context.
- **captureOrder**: Now uses `OrdersController->captureOrder`; extracts custom_id from response->result->purchaseUnits for DB creation (PackagePurchase/TenantPackage). - **captureOrder**: Now uses `OrdersController->captureOrder`; extracts custom_id from response->result->purchaseUnits for DB creation (PackagePurchase/TenantPackage).
@@ -14,9 +14,9 @@ Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-s
- **Documentation**: Updated docs/prp/08-billing.md to reflect new SDK usage, flow, and migration notes. - **Documentation**: Updated docs/prp/08-billing.md to reflect new SDK usage, flow, and migration notes.
## Testing ## Testing
- Unit/Feature Tests: All PayPal-related tests pass with mocks simulating new API responses (statusCode 201, result structure). - Unit/Feature Tests: All Paddle-related tests pass with mocks simulating new API responses (statusCode 201, result structure).
- Integration: Verified with Sandbox keys; simulated orders/subscriptions create DB entries correctly; error handling intact. - Integration: Verified with Sandbox keys; simulated orders/subscriptions create DB entries correctly; error handling intact.
- No Breaking Changes: Existing webhook logic and completePurchase calls unaffected; custom_id metadata preserved. - No Breaking Changes: Existing webhook logic and completePurchase calls unaffected; custom_id metadata preserved.
## Rationale ## Rationale
The old SDK is deprecated and not recommended by PayPal. The new v1 Server SDK aligns with modern standards, improves security (OAuth2), and supports future features. Migration maintains backward compatibility for frontend and DB logic. The old SDK is deprecated and not recommended by Paddle. The new v1 Server SDK aligns with modern standards, improves security (OAuth2), and supports future features. Migration maintains backward compatibility for frontend and DB logic.

View File

@@ -38,7 +38,7 @@
- **Credits strip**: `credits-card` combines balance chips, a RevenueCat-aware badge, and CTA to `/credits-store`; replicating this card gives tenants a quick read on package status. - **Credits strip**: `credits-card` combines balance chips, a RevenueCat-aware badge, and CTA to `/credits-store`; replicating this card gives tenants a quick read on package status.
### Monetisation & Ordering ### Monetisation & Ordering
- **IAP store** (`pages/IAPStorePage.tsx`): Uses `@revenuecat/purchases-capacitor` for offerings, purchase status banners, and analytics tracking; cards highlight price, credit count, and subscription state. Needs Stripe/PayPal parity discussion before porting. - **IAP store** (`pages/IAPStorePage.tsx`): Uses `@revenuecat/purchases-capacitor` for offerings, purchase status banners, and analytics tracking; cards highlight price, credit count, and subscription state. Needs Paddle parity discussion before porting.
- **Credits context** (`contexts/AuthContext.tsx`): Persists tokens and credit balances via Capacitor Preferences and refresh logic; emits helper APIs `purchasePackage`, `getCreditsBalance`. - **Credits context** (`contexts/AuthContext.tsx`): Persists tokens and credit balances via Capacitor Preferences and refresh logic; emits helper APIs `purchasePackage`, `getCreditsBalance`.
### Event Creation Wizard ### Event Creation Wizard
@@ -57,7 +57,7 @@
**Porting Recommendation** **Porting Recommendation**
- Rebuild the hero, feature cards, quick actions, and wizard using Tailwind + shadcn components inside Laravel PWA while reusing copy/structure. - Rebuild the hero, feature cards, quick actions, and wizard using Tailwind + shadcn components inside Laravel PWA while reusing copy/structure.
- Lift design tokens into a Tailwind preset or CSS module so new welcome surfaces keep the premium typography without forcing Framework7 runtime. - Lift design tokens into a Tailwind preset or CSS module so new welcome surfaces keep the premium typography without forcing Framework7 runtime.
- Treat RevenueCat-specific logic as optional: plan abstraction so Stripe/PayPal packages in Laravel can slot in later if we skip native IAPs initially. - Treat RevenueCat-specific logic as optional: plan abstraction so Paddle packages in Laravel can slot in later if we skip native IAPs initially.
## Proposed Laravel PWA Welcome Primitives ## Proposed Laravel PWA Welcome Primitives
- **`TenantWelcomeLayout`**: Full-height, gradient-backed shell with centered content column, safe-area padding, and optional bottom action rail. Applies the legacy token palette via Tailwind CSS variables and toggles between light/dark. - **`TenantWelcomeLayout`**: Full-height, gradient-backed shell with centered content column, safe-area padding, and optional bottom action rail. Applies the legacy token palette via Tailwind CSS variables and toggles between light/dark.
@@ -71,13 +71,13 @@
These primitives live under `resources/js/admin/onboarding/` and integrate with current router constants (`ADMIN_BASE_PATH`). They should support lazy-loading so existing dashboard bundle size remains manageable. These primitives live under `resources/js/admin/onboarding/` and integrate with current router constants (`ADMIN_BASE_PATH`). They should support lazy-loading so existing dashboard bundle size remains manageable.
## Progress ## Progress
- **Inline Checkout**: Die Order-Summary-Seite unterstützt jetzt Stripe-Kartenzahlungen (Payment Element) und PayPal (Orders API) direkt aus dem Onboarding heraus. Free-Packages lassen sich ohne Umweg aktivieren. - **Inline Checkout**: Die Order-Summary-Seite unterstützt jetzt Stripe-Kartenzahlungen (Payment Element) und Paddle (Orders API) direkt aus dem Onboarding heraus. Free-Packages lassen sich ohne Umweg aktivieren.
- Dashboard bewirbt die Welcome Journey (Actions + Hero Card) und leitet Tenants ohne Events weiterhin auf `/event-admin/welcome` um, während Fortschritt persistiert wird. - Dashboard bewirbt die Welcome Journey (Actions + Hero Card) und leitet Tenants ohne Events weiterhin auf `/event-admin/welcome` um, während Fortschritt persistiert wird.
- Playwright-Skelett `tests/e2e/tenant-onboarding-flow.test.ts` angelegt und via `npm run test:e2e` ausführbar; Tests sind vorerst deaktiviert, bis Seed-Daten + Auth-Helper zur Verfügung stehen. - Playwright-Skelett `tests/e2e/tenant-onboarding-flow.test.ts` angelegt und via `npm run test:e2e` ausführbar; Tests sind vorerst deaktiviert, bis Seed-Daten + Auth-Helper zur Verfügung stehen.
- Welcome Landing, Packages, Summary und Event-Setup sind zweisprachig (DE/EN) via react-i18next; LanguageSwitcher im Dashboard & Welcome-Layout steuert die Locale. - Welcome Landing, Packages, Summary und Event-Setup sind zweisprachig (DE/EN) via react-i18next; LanguageSwitcher im Dashboard & Welcome-Layout steuert die Locale.
## Status — verbleibende Arbeiten ## Status — verbleibende Arbeiten
- PayPal-Testabdeckung (Playwright/RTL) und Error-UX gehören noch in die Roadmap, ebenso wie End-to-End-Validierung auf Staging. - Paddle-Testabdeckung (Playwright/RTL) und Error-UX gehören noch in die Roadmap, ebenso wie End-to-End-Validierung auf Staging.
## Notes ## Notes
- Keep current management modules untouched until welcome flow is ready; ship incrementally behind feature flag if needed. - Keep current management modules untouched until welcome flow is ready; ship incrementally behind feature flag if needed.

View File

@@ -9,16 +9,16 @@ Frage klären: kann man den login oder die registrierung ersetzen durch daten vo
schritt 3: Zahlung schritt 3: Zahlung
pakettyp "endcustomer": pakettyp "endcustomer":
auswahl paypal / Stripe. Buttons für "Mit PayPal bezahlen" und "Mit Stripe bezahlen" anzeigen. Der Benutzer klickt einen aus. auswahl paypal / Stripe. Buttons für "Mit Paddle bezahlen" und "Mit Stripe bezahlen" anzeigen. Der Benutzer klickt einen aus.
Zahlungsinitierung: Zahlungsinitierung:
PayPal: Umleitung zu PayPal's Express Checkout (via API-Call in Laravel-Controller, z. B. create_order). Der Benutzer loggt sich bei PayPal ein, bestätigt den Einmalkauf (keine Subscription-Option). Rückleitung mit Token zur Bestätigung (Webhook oder Redirect-Handler). Paddle: Umleitung zu Paddle's Express Checkout (via API-Call in Laravel-Controller, z. B. create_order). Der Benutzer loggt sich bei Paddle ein, bestätigt den Einmalkauf (keine Subscription-Option). Rückleitung mit Token zur Bestätigung (Webhook oder Redirect-Handler).
Stripe: Client-seitige Integration mit Stripe Elements (React-Komponente in Ihrer PWA). Der Benutzer gibt Kartendaten ein (ohne Umleitung), oder nutzt Stripe Checkout (hosted Page). Backend-Call zu Stripe API für PaymentIntent erstellen und bestätigen. Stripe: Client-seitige Integration mit Stripe Elements (React-Komponente in Ihrer PWA). Der Benutzer gibt Kartendaten ein (ohne Umleitung), oder nutzt Stripe Checkout (hosted Page). Backend-Call zu Stripe API für PaymentIntent erstellen und bestätigen.
Bestätigung: Nach Zahlung (z. B. 29,99 €) wird der Kauf im Backend gespeichert (z. B. TenantPackage::createPurchase()), Zugang freigeschaltet (z. B. Event-Zugriff via EventController), und der Benutzer sieht eine Erfolgsseite. Bestätigung: Nach Zahlung (z. B. 29,99 €) wird der Kauf im Backend gespeichert (z. B. TenantPackage::createPurchase()), Zugang freigeschaltet (z. B. Event-Zugriff via EventController), und der Benutzer sieht eine Erfolgsseite.
Fehlerbehandlung: Abbruch → Zurück zur Bestellübersicht mit Fehlermeldung (z. B. "Zahlung fehlgeschlagen"). Fehlerbehandlung: Abbruch → Zurück zur Bestellübersicht mit Fehlermeldung (z. B. "Zahlung fehlgeschlagen").
pakettyp "reseller": pakettyp "reseller":
PayPal: Paddle:
Nutzung von PayPal Subscriptions API (in Laravel via SDK). Erstellen eines Subscription-Plans (z. B. create_subscription), Umleitung zu PayPal für Autorisierung. Der Benutzer stimmt wiederkehrenden Abbuchungen zu. Rückleitung mit Subscription-ID, die im Backend (z. B. PackagePurchases) gespeichert wird. Webhooks für Updates (z. B. Kündigung). Nutzung von Paddle Subscriptions API (in Laravel via SDK). Erstellen eines Subscription-Plans (z. B. create_subscription), Umleitung zu Paddle für Autorisierung. Der Benutzer stimmt wiederkehrenden Abbuchungen zu. Rückleitung mit Subscription-ID, die im Backend (z. B. PackagePurchases) gespeichert wird. Webhooks für Updates (z. B. Kündigung).
Stripe: Stripe:
Erstellen eines Subscription-Plans via Stripe Dashboard/API (z. B. stripe.subscriptions.create()). Client-seitig: Stripe Elements für SetupIntent (Kartenspeicherung), dann Subscription aktivieren. Keine Umleitung nötig, wenn Sie benutzerdefinierte UI bauen. Backend-Handling für Billing-Cycles, Invoices und Webhooks (z. B. für invoice.paid). Erstellen eines Subscription-Plans via Stripe Dashboard/API (z. B. stripe.subscriptions.create()). Client-seitig: Stripe Elements für SetupIntent (Kartenspeicherung), dann Subscription aktivieren. Keine Umleitung nötig, wenn Sie benutzerdefinierte UI bauen. Backend-Handling für Billing-Cycles, Invoices und Webhooks (z. B. für invoice.paid).
Bestätigung: Erste Zahlung erfolgt sofort, Subscription startet. Backend-Update: Reseller-Status aktivieren (z. B. in Tenants-Tabelle), Willkommens-E-Mail. Bestätigung: Erste Zahlung erfolgt sofort, Subscription startet. Backend-Update: Reseller-Status aktivieren (z. B. in Tenants-Tabelle), Willkommens-E-Mail.

View File

@@ -57,9 +57,9 @@ Der Anbieter verwendet Inhalte ausschließlich zur technischen Bereitstellung (S
## 7. Preise und Zahlung ## 7. Preise und Zahlung
1. Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung. 1. Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung.
2. Alle Preise verstehen sich einschließlich gesetzlicher Umsatzsteuer. 2. Alle Preise verstehen sich einschließlich gesetzlicher Umsatzsteuer.
3. Die Zahlung erfolgt im Voraus über **PayPal** oder **Stripe Checkout** (Kreditkarte, Apple Pay, Google Pay u. a.). 3. Die Zahlung erfolgt im Voraus über **Paddle** oder **Stripe Checkout** (Kreditkarte, Apple Pay, Google Pay u. a.).
4. Bei Nutzung dieser Dienste gelten zusätzlich die AGB und Datenschutzhinweise der jeweiligen Anbieter: 4. Bei Nutzung dieser Dienste gelten zusätzlich die AGB und Datenschutzhinweise der jeweiligen Anbieter:
PayPal (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg Paddle (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg
Stripe Payments Europe Ltd., Dublin, Irland Stripe Payments Europe Ltd., Dublin, Irland
5. Der Anbieter erhält von diesen Diensten nur Zahlungs- und Statusinformationen zur Abwicklung. 5. Der Anbieter erhält von diesen Diensten nur Zahlungs- und Statusinformationen zur Abwicklung.
6. Rechnungen werden elektronisch bereitgestellt. 6. Rechnungen werden elektronisch bereitgestellt.

View File

@@ -57,9 +57,9 @@ The Provider uses such content solely for technical purposes (storage, display,
## 7. Prices and Payment ## 7. Prices and Payment
1. Prices valid at the time of booking apply. 1. Prices valid at the time of booking apply.
2. All prices include VAT, unless otherwise stated. 2. All prices include VAT, unless otherwise stated.
3. Payment is made in advance via **PayPal** or **Stripe Checkout** (credit card, Apple Pay, Google Pay, etc.). 3. Payment is made in advance via **Paddle** or **Stripe Checkout** (credit card, Apple Pay, Google Pay, etc.).
4. The payment process is governed by the respective providers terms: 4. The payment process is governed by the respective providers terms:
PayPal (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg Paddle (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg
Stripe Payments Europe Ltd., Dublin, Ireland Stripe Payments Europe Ltd., Dublin, Ireland
5. The Provider only receives transaction and payment status data necessary for processing. 5. The Provider only receives transaction and payment status data necessary for processing.
6. Invoices are issued electronically. 6. Invoices are issued electronically.

View File

@@ -21,7 +21,7 @@ Die Nutzung der Fotospiel App ist grundsätzlich nur mit den personenbezogenen D
--- ---
## 3. Arten der verarbeiteten Daten ## 3. Arten der verarbeiteten Daten
- Veranstalterdaten: Name, E-Mail-Adresse, Zahlungsinformationen (über PayPal/Stripe), Eventdaten (Titel, Datum, Aufgaben, Bilder) - Veranstalterdaten: Name, E-Mail-Adresse, Zahlungsinformationen (über Paddle/Stripe), Eventdaten (Titel, Datum, Aufgaben, Bilder)
- Nutzerdaten (Gäste): hochgeladene Fotos, Anzeigename (frei wählbar), Reaktionen/Likes - Nutzerdaten (Gäste): hochgeladene Fotos, Anzeigename (frei wählbar), Reaktionen/Likes
- Technische Daten: IP-Adresse, Browsertyp, Zeitstempel, Geräteinformationen - Technische Daten: IP-Adresse, Browsertyp, Zeitstempel, Geräteinformationen
- Kommunikationsdaten: Inhalte von Kontaktanfragen über das Formular oder per E-Mail - Kommunikationsdaten: Inhalte von Kontaktanfragen über das Formular oder per E-Mail
@@ -33,7 +33,7 @@ Die Nutzung der Fotospiel App ist grundsätzlich nur mit den personenbezogenen D
|------------------------|----------------|---------------| |------------------------|----------------|---------------|
| Bereitstellung der App und Durchführung von Veranstaltungen | Art. 6 Abs. 1 lit. b DSGVO | Nutzung der App durch Veranstalter und Gäste | | Bereitstellung der App und Durchführung von Veranstaltungen | Art. 6 Abs. 1 lit. b DSGVO | Nutzung der App durch Veranstalter und Gäste |
| Speicherung und Anzeige von Fotos innerhalb des Events | Art. 6 Abs. 1 lit. b DSGVO | Durchführung der Fotospiel-Funktionalität | | Speicherung und Anzeige von Fotos innerhalb des Events | Art. 6 Abs. 1 lit. b DSGVO | Durchführung der Fotospiel-Funktionalität |
| Abrechnung und Zahlungsabwicklung | Art. 6 Abs. 1 lit. b, lit. c DSGVO | Nutzung der Dienste von PayPal und Stripe | | Abrechnung und Zahlungsabwicklung | Art. 6 Abs. 1 lit. b, lit. c DSGVO | Nutzung der Dienste von Paddle und Stripe |
| Webanalyse über Matomo (selbst gehostet) | Art. 6 Abs. 1 lit. f DSGVO | Statistische Auswertung zur Verbesserung der App | | Webanalyse über Matomo (selbst gehostet) | Art. 6 Abs. 1 lit. f DSGVO | Statistische Auswertung zur Verbesserung der App |
| Sicherheit, Server-Logs | Art. 6 Abs. 1 lit. f DSGVO | Sicherstellung des Betriebs, Fehleranalyse | | Sicherheit, Server-Logs | Art. 6 Abs. 1 lit. f DSGVO | Sicherstellung des Betriebs, Fehleranalyse |
| Beantwortung von Kontaktanfragen | Art. 6 Abs. 1 lit. f oder lit. b DSGVO | Kommunikation mit Nutzern und Interessenten | | Beantwortung von Kontaktanfragen | Art. 6 Abs. 1 lit. f oder lit. b DSGVO | Kommunikation mit Nutzern und Interessenten |
@@ -48,13 +48,13 @@ Die Verarbeitung erfolgt ausschließlich innerhalb der EU.
--- ---
## 6. Zahlungsabwicklung ## 6. Zahlungsabwicklung
Die Zahlungsabwicklung erfolgt über **PayPal (Europe) S.à r.l. et Cie, S.C.A.** und **Stripe Payments Europe, Ltd.** Die Zahlungsabwicklung erfolgt über **Paddle (Europe) S.à r.l. et Cie, S.C.A.** und **Stripe Payments Europe, Ltd.**
Bei der Zahlung werden personenbezogene Daten an diese Dienstleister übermittelt. Bei der Zahlung werden personenbezogene Daten an diese Dienstleister übermittelt.
Wir speichern keine Zahlungs- oder Kreditkartendaten. Wir speichern keine Zahlungs- oder Kreditkartendaten.
Rechtsgrundlage: Art. 6 Abs. 1 lit. b und lit. c DSGVO. Rechtsgrundlage: Art. 6 Abs. 1 lit. b und lit. c DSGVO.
Datenschutzhinweise der Anbieter: Datenschutzhinweise der Anbieter:
- PayPal: https://www.paypal.com/de/webapps/mpp/ua/privacy-full - Paddle: https://www.paypal.com/de/webapps/mpp/ua/privacy-full
- Stripe: https://stripe.com/de/privacy - Stripe: https://stripe.com/de/privacy
--- ---
@@ -88,7 +88,7 @@ Eine Einwilligung ist nicht erforderlich.
## 10. Weitergabe an Dritte ## 10. Weitergabe an Dritte
Eine Weitergabe erfolgt nur an: Eine Weitergabe erfolgt nur an:
- Zahlungsdienstleister (PayPal, Stripe) - Zahlungsdienstleister (Paddle, Stripe)
- Hoster (Hetzner) - Hoster (Hetzner)
- Gesetzlich erforderliche Stellen (z. B. Finanzbehörden) - Gesetzlich erforderliche Stellen (z. B. Finanzbehörden)

View File

@@ -21,7 +21,7 @@ Use of the Fotospiel App requires only the personal data necessary to host and p
--- ---
## 3. Types of Data Processed ## 3. Types of Data Processed
- Organizer data: name, email address, payment information (via PayPal/Stripe), event details (title, date, photo tasks, photos) - Organizer data: name, email address, payment information (via Paddle/Stripe), event details (title, date, photo tasks, photos)
- Guest data: uploaded photos, display name (optional), likes/reactions - Guest data: uploaded photos, display name (optional), likes/reactions
- Technical data: IP address, browser type, timestamp, device information - Technical data: IP address, browser type, timestamp, device information
- Communication data: messages sent via contact form or email - Communication data: messages sent via contact form or email
@@ -33,7 +33,7 @@ Use of the Fotospiel App requires only the personal data necessary to host and p
|----------|--------------|-------------| |----------|--------------|-------------|
| Providing the app and hosting events | Art. 6(1)(b) GDPR | Contract performance | | Providing the app and hosting events | Art. 6(1)(b) GDPR | Contract performance |
| Storing and displaying photos | Art. 6(1)(b) GDPR | Core feature of the app | | Storing and displaying photos | Art. 6(1)(b) GDPR | Core feature of the app |
| Payment processing and invoicing | Art. 6(1)(b), (c) GDPR | Use of PayPal and Stripe services | | Payment processing and invoicing | Art. 6(1)(b), (c) GDPR | Use of Paddle and Stripe services |
| Web analytics via Matomo | Art. 6(1)(f) GDPR | Statistical analysis to improve the app | | Web analytics via Matomo | Art. 6(1)(f) GDPR | Statistical analysis to improve the app |
| Server logs and security | Art. 6(1)(f) GDPR | Ensuring system security | | Server logs and security | Art. 6(1)(f) GDPR | Ensuring system security |
| Responding to inquiries | Art. 6(1)(f) or (b) GDPR | Communication with users | | Responding to inquiries | Art. 6(1)(f) or (b) GDPR | Communication with users |
@@ -48,12 +48,12 @@ All processing takes place within the EU.
--- ---
## 6. Payment Processing ## 6. Payment Processing
Payments are handled by **PayPal (Europe) S.à r.l. et Cie, S.C.A.** and **Stripe Payments Europe, Ltd.** Payments are handled by **Paddle (Europe) S.à r.l. et Cie, S.C.A.** and **Stripe Payments Europe, Ltd.**
We do not store payment or credit card data. We do not store payment or credit card data.
Legal basis: Art. 6(1)(b) and (c) GDPR. Legal basis: Art. 6(1)(b) and (c) GDPR.
Privacy policies: Privacy policies:
- PayPal: https://www.paypal.com/de/webapps/mpp/ua/privacy-full - Paddle: https://www.paypal.com/de/webapps/mpp/ua/privacy-full
- Stripe: https://stripe.com/de/privacy - Stripe: https://stripe.com/de/privacy
--- ---
@@ -87,7 +87,7 @@ No consent is required.
## 10. Data Disclosure ## 10. Data Disclosure
Data is only shared with: Data is only shared with:
- Payment providers (PayPal, Stripe) - Payment providers (Paddle, Stripe)
- Hosting provider (Hetzner) - Hosting provider (Hetzner)
- Public authorities when legally required - Public authorities when legally required

View File

@@ -21,10 +21,10 @@ Das bestehende Modell ist Credits-basiert (Freemium mit 1 Free-Credit, One-off-K
- **API:** Endpunkte `/api/v1/tenant/credits/balance`, `/credits/ledger`, `/credits/purchase`, `/credits/sync`, `/purchases/intent`. - **API:** Endpunkte `/api/v1/tenant/credits/balance`, `/credits/ledger`, `/credits/purchase`, `/credits/sync`, `/purchases/intent`.
- **Frontend (Admin PWA):** Dashboard-Cards für Balance, Kauf-Integration (RevenueCat). - **Frontend (Admin PWA):** Dashboard-Cards für Balance, Kauf-Integration (RevenueCat).
- **Guest PWA:** Keine direkten Checks (Backend-handhabt). - **Guest PWA:** Keine direkten Checks (Backend-handhabt).
- **Billing:** Stripe (Checkout/Webhooks), RevenueCat (IAP), PayPalWebhookController (teilweise). - **Billing:** Stripe (Checkout/Webhooks), RevenueCat (IAP), PaddleWebhookController (teilweise).
- **Tests:** `RevenueCatWebhookTest`, Credit-Unit-Tests. - **Tests:** `RevenueCatWebhookTest`, Credit-Unit-Tests.
- **Docs:** PRP 08-billing.md (Credits-MVP), 14-freemium-business-model.md (IAP-Struktur), API-Specs (credits-Endpunkte). - **Docs:** PRP 08-billing.md (Credits-MVP), 14-freemium-business-model.md (IAP-Struktur), API-Specs (credits-Endpunkte).
- **Lücken im Aktuellen:** Keine Package-Limits (nur Balance), Subscriptions nicht live, PayPal untergenutzt. - **Lücken im Aktuellen:** Keine Package-Limits (nur Balance), Subscriptions nicht live, Paddle untergenutzt.
**Auswirkungen:** Vollständige Ersetzung, um Flexibilität (Limits/Features pro Package) zu ermöglichen. **Auswirkungen:** Vollständige Ersetzung, um Flexibilität (Limits/Features pro Package) zu ermöglichen.
@@ -99,7 +99,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
$table->foreignId('tenant_id')->nullable()->constrained(); $table->foreignId('tenant_id')->nullable()->constrained();
$table->foreignId('event_id')->nullable()->constrained(); $table->foreignId('event_id')->nullable()->constrained();
$table->foreignId('package_id')->constrained(); $table->foreignId('package_id')->constrained();
$table->string('provider_id'); // Stripe/PayPal ID $table->string('provider_id'); // Paddle ID
$table->decimal('price', 8, 2); $table->decimal('price', 8, 2);
$table->enum('type', ['endcustomer_event', 'reseller_subscription']); $table->enum('type', ['endcustomer_event', 'reseller_subscription']);
$table->json('metadata'); // {event_id, ip_address} $table->json('metadata'); // {event_id, ip_address}
@@ -129,13 +129,13 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
- **TenantPackageResource (SuperAdmin/TenantAdmin):** - **TenantPackageResource (SuperAdmin/TenantAdmin):**
- Form: Select('tenant_id'), Select('package_id'), DateTimePicker('purchased_at'), DateTimePicker('expires_at'), TextInput('used_events', readOnly), Toggle('active'). - Form: Select('tenant_id'), Select('package_id'), DateTimePicker('purchased_at'), DateTimePicker('expires_at'), TextInput('used_events', readOnly), Toggle('active').
- Table: TextColumn('tenant.name'), BadgeColumn('package.name'), DateColumn('expires_at', color: expired → danger), ProgressColumn('used_events' / max_events), Actions (Renew: set expires_at +1 year, Cancel: active=false + Stripe/PayPal cancel). - Table: TextColumn('tenant.name'), BadgeColumn('package.name'), DateColumn('expires_at', color: expired → danger), ProgressColumn('used_events' / max_events), Actions (Renew: set expires_at +1 year, Cancel: active=false + Paddle cancel).
- Relations: BelongsTo Tenant/Package, HasMany Events (RelationManager mit Event-List). - Relations: BelongsTo Tenant/Package, HasMany Events (RelationManager mit Event-List).
- Bulk-Actions: Renew Selected. - Bulk-Actions: Renew Selected.
- **PurchaseResource (SuperAdmin/TenantAdmin):** - **PurchaseResource (SuperAdmin/TenantAdmin):**
- Form: Select('tenant_id/event_id'), Select('package_id'), TextInput('provider_id'), MoneyInput('price'), Select('type'), JSONEditor('metadata'), Toggle('refunded'). - Form: Select('tenant_id/event_id'), Select('package_id'), TextInput('provider_id'), MoneyInput('price'), Select('type'), JSONEditor('metadata'), Toggle('refunded').
- Table: BadgeColumn('type'), LinkColumn('tenant' or 'event'), TextColumn('package.name/price'), DateColumn('purchased_at'), BadgeColumn('status' paid/refunded), Actions (View, Refund: Call Stripe/PayPal API, decrement counters, log). - Table: BadgeColumn('type'), LinkColumn('tenant' or 'event'), TextColumn('package.name/price'), DateColumn('purchased_at'), BadgeColumn('status' paid/refunded), Actions (View, Refund: Call Paddle API, decrement counters, log).
- Filters: SelectFilter('type'), DateRangeFilter('purchased_at'), TenantFilter. - Filters: SelectFilter('type'), DateRangeFilter('purchased_at'), TenantFilter.
- Widgets: StatsOverview (Total Revenue, Monthly Purchases, Top Package), ChartWidget (Revenue over Time via Laravel Charts). - Widgets: StatsOverview (Total Revenue, Monthly Purchases, Top Package), ChartWidget (Revenue over Time via Laravel Charts).
- Export: CSV (für Buchhaltung: tenant, package, price, date). - Export: CSV (für Buchhaltung: tenant, package, price, date).
@@ -145,14 +145,14 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
## 5. Marketing- und Legal-Anpassungen (Todo 4) ## 5. Marketing- und Legal-Anpassungen (Todo 4)
- **Webfrontend (Blade, resources/views/marketing/):** - **Webfrontend (Blade, resources/views/marketing/):**
- **packages.blade.php (neu, Route /packages):** Hero ("Entdecken Sie unsere Packages"), Tabs (Endkunden/Reseller), Tabelle/Accordion mit Details (Preis, Limits als Icons, Features-Bullets, i18n-Übersetzungen). CTA: "Kaufen" → /checkout/{id}. Dynamisch: @foreach(Package::where('type', 'endcustomer')->get() as $package). - **packages.blade.php (neu, Route /packages):** Hero ("Entdecken Sie unsere Packages"), Tabs (Endkunden/Reseller), Tabelle/Accordion mit Details (Preis, Limits als Icons, Features-Bullets, i18n-Übersetzungen). CTA: "Kaufen" → /checkout/{id}. Dynamisch: @foreach(Package::where('type', 'endcustomer')->get() as $package).
- **checkout.blade.php (neu, Route /checkout/{package_id}):** Summary-Box (Package-Details), Form (Name, E-Mail, Adresse für Reseller), Zahlungsoptionen (Radio: Stripe/PayPal), Stripe-Element/PayPal-Button. Submit: POST /purchases/intent → Redirect. Tailwind: Secure-Design mit Badges. - **checkout.blade.php (neu, Route /checkout/{package_id}):** Summary-Box (Package-Details), Form (Name, E-Mail, Adresse für Reseller), Zahlungsoptionen (Radio: Paddle), Stripe-Element/Paddle-Button. Submit: POST /purchases/intent → Redirect. Tailwind: Secure-Design mit Badges.
- **success.blade.php:** "Vielen Dank! Package {name} gekauft." Details (Limits, Event-Link), Upsell ("Upgrade zu Reseller?"), Rechnung-Download (PDF via Dompdf), Onboarding-Tour-Link. - **success.blade.php:** "Vielen Dank! Package {name} gekauft." Details (Limits, Event-Link), Upsell ("Upgrade zu Reseller?"), Rechnung-Download (PDF via Dompdf), Onboarding-Tour-Link.
- **marketing.blade.php:** Teaser-Section mit Package-Icons/Preisen, Link zu /packages. - **marketing.blade.php:** Teaser-Section mit Package-Icons/Preisen, Link zu /packages.
- **occasions.blade.php/blog*.blade.php:** Kontextuelle Erwähnungen (z.B. "Ideal für Partys: Starter-Paket"), Blog-Post "Neues Package-Modell" mit FAQ. - **occasions.blade.php/blog*.blade.php:** Kontextuelle Erwähnungen (z.B. "Ideal für Partys: Starter-Paket"), Blog-Post "Neues Package-Modell" mit FAQ.
- **Legal (resources/views/legal/):** - **Legal (resources/views/legal/):**
- **datenschutz.blade.php:** Abschnitt "Zahlungen" (Stripe/PayPal: Keine Karten-Speicherung, GDPR: Löschung nach 10 Jahren; Consent für E-Mails). "Package-Daten (Limits) sind anonymisiert." - **datenschutz.blade.php:** Abschnitt "Zahlungen" (Paddle: Keine Karten-Speicherung, GDPR: Löschung nach 10 Jahren; Consent für E-Mails). "Package-Daten (Limits) sind anonymisiert."
- **impressum.blade.php:** "Monetarisierung: Packages via Stripe/PayPal; USt-ID: ...; Support: support@fotospiel.de". - **impressum.blade.php:** "Monetarisierung: Packages via Paddle; USt-ID: ...; Support: support@fotospiel.de".
- **Allgemein:** Datum "Aktualisiert: 2025-09-26 Package-Modell"; Links zu Provider-Datenschutz. - **Allgemein:** Datum "Aktualisiert: 2025-09-26 Package-Modell"; Links zu Provider-Datenschutz.
**i18n:** Translations in lang/de/en (z.B. 'package.starter' → 'Starter-Paket'). **i18n:** Translations in lang/de/en (z.B. 'package.starter' → 'Starter-Paket').
@@ -160,7 +160,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
## 6. Backend-Logik & API (Todo 6/7) ## 6. Backend-Logik & API (Todo 6/7)
- **Controllers:** - **Controllers:**
- `PackagesController` (index: Liste mit Cache, show: Details, store: Intent für Kauf). - `PackagesController` (index: Liste mit Cache, show: Details, store: Intent für Kauf).
- `PurchasesController` (intent: Erstelle Stripe-Session oder PayPal-Order basierend auf method; store: Nach Webhook). - `PurchasesController` (intent: Erstelle Stripe-Session oder Paddle-Order basierend auf method; store: Nach Webhook).
- **Middleware:** `PackageMiddleware` (für Events: Check event_packages.used_photos < max_photos; für Tenant: used_events < max_events_per_year). - **Middleware:** `PackageMiddleware` (für Events: Check event_packages.used_photos < max_photos; für Tenant: used_events < max_events_per_year).
- **Models:** `Package` (Relationships: hasMany EventPackage/TenantPackage), `EventPackage` (incrementUsedPhotos-Method), `TenantPackage` (isActive-Scope, Observer für Expiry: E-Mail + active=false). - **Models:** `Package` (Relationships: hasMany EventPackage/TenantPackage), `EventPackage` (incrementUsedPhotos-Method), `TenantPackage` (isActive-Scope, Observer für Expiry: E-Mail + active=false).
- **API-Endpunkte (routes/api.php, tenant-group):** - **API-Endpunkte (routes/api.php, tenant-group):**
@@ -175,7 +175,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
## 7. Frontend-Anpassungen (Todo 8/9) ## 7. Frontend-Anpassungen (Todo 8/9)
- **Admin PWA (resources/js/admin/):** - **Admin PWA (resources/js/admin/):**
- EventFormPage.tsx: Select('package_id') mit Details-Modal (Limits/Preis), Button 'Kaufen' → Stripe/PayPal-Integration (stripe.elements oder PayPal-Button). - EventFormPage.tsx: Select('package_id') mit Details-Modal (Limits/Preis), Button 'Kaufen' → Paddle-Integration (stripe.elements oder Paddle-Button).
- Dashboard: Card 'Aktuelles Package' (Limits, Expiry, Upgrade-Button). - Dashboard: Card 'Aktuelles Package' (Limits, Expiry, Upgrade-Button).
- SettingsPage.tsx: Reseller-Übersicht (used_events/Progress, Renew-Button). - SettingsPage.tsx: Reseller-Übersicht (used_events/Progress, Renew-Button).
- Hooks: usePackageLimits (fetch /packages, check used_photos). - Hooks: usePackageLimits (fetch /packages, check used_photos).
@@ -185,21 +185,21 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
- Features: Watermark-Overlay if watermark_allowed; Branding-Logo if branding_allowed. - Features: Watermark-Overlay if watermark_allowed; Branding-Logo if branding_allowed.
- Router: Guard für Limits (z.B. /upload → Check API). - Router: Guard für Limits (z.B. /upload → Check API).
**Tech:** React Query für API-Calls, Stripe.js/PayPal-SDK in Components, i18n mit react-i18next. **Tech:** React Query für API-Calls, Stripe.js/Paddle-SDK in Components, i18n mit react-i18next.
## 8. Billing-Integration (Todo 10) ## 8. Billing-Integration (Todo 10)
- **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + PayPal (Alternative: PHP SDK für Orders/Subscriptions). - **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + Paddle (Alternative: PHP SDK für Orders/Subscriptions).
- **Flow:** Auswahl → Intent (Controller: if 'stripe' → Stripe::checkout()->sessions->create([...]); if 'paypal' → PayPal::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail). - **Flow:** Auswahl → Intent (Controller: if 'stripe' → Stripe::checkout()->sessions->create([...]); if 'paypal' → Paddle::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail).
- **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PayPalWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase). - **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PaddleWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase).
- **SDKs:** composer require stripe/stripe-php ^10.0, paypal/rest-api-sdk-php ^1.14; NPM: @stripe/stripe-js, @paypal/react-paypal-js. - **SDKs:** composer require stripe/stripe-php ^10.0, paypal/rest-api-sdk-php ^1.14; NPM: @stripe/stripe-js, @paypal/react-paypal-js.
- **Free:** Kein Provider direkt assign via API. - **Free:** Kein Provider direkt assign via API.
- **Refunds:** Action in PurchaseResource: Call Stripe::refunds->create oder PayPal::refunds, decrement Counters. - **Refunds:** Action in PurchaseResource: Call Stripe::refunds->create oder Paddle::refunds, decrement Counters.
- **Env:** STRIPE_KEY/SECRET, PAYPAL_CLIENT_ID/SECRET, SANDBOX-Flags. - **Env:** STRIPE_KEY/SECRET, PAYPAL_CLIENT_ID/SECRET, SANDBOX-Flags.
## 9. Tests (Todo 11) ## 9. Tests (Todo 11)
- **Unit/Feature:** Pest/PHPUnit: Test PackageSeeder, Migration (assert Tables exist), Controllers (mock Stripe/PayPal SDKs mit Stripe::mock(), test Intent/Webhook), Models (Package::find(1)->limits, TenantPackage::isActive), Middleware (assert denies if limit exceeded). - **Unit/Feature:** Pest/PHPUnit: Test PackageSeeder, Migration (assert Tables exist), Controllers (mock Paddle SDKs mit Stripe::mock(), test Intent/Webhook), Models (Package::find(1)->limits, TenantPackage::isActive), Middleware (assert denies if limit exceeded).
- **E2E (Playwright):** Test Kauf-Flow (navigate /packages, select Starter, choose PayPal, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max). - **E2E (Playwright):** Test Kauf-Flow (navigate /packages, select Starter, choose Paddle, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max).
- **Anpassungen:** RevenueCatWebhookTest → Stripe/PayPalWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422). - **Anpassungen:** RevenueCatWebhookTest → PaddleWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422).
- **Coverage:** 80% für Billing/DB; Mock Providers für Isolation. - **Coverage:** 80% für Billing/DB; Mock Providers für Isolation.
## 10. Deployment & Rollout (Todo 12) ## 10. Deployment & Rollout (Todo 12)
@@ -222,8 +222,8 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
- **Support:** E-Mail-Templates (PurchaseMailable), FAQ in /support/packages, Onboarding-Tour post-Kauf. - **Support:** E-Mail-Templates (PurchaseMailable), FAQ in /support/packages, Onboarding-Tour post-Kauf.
- **Performance:** Caching (Packages-Liste), Indexing (purchased_at), Queues für Webhooks (ProcessPurchaseJob). - **Performance:** Caching (Packages-Liste), Indexing (purchased_at), Queues für Webhooks (ProcessPurchaseJob).
- **Edge-Cases:** Upgrade (prorate Preis, transfer Limits), Expiry (Observer + E-Mail), Offline-PWA (queued Käufe sync). - **Edge-Cases:** Upgrade (prorate Preis, transfer Limits), Expiry (Observer + E-Mail), Offline-PWA (queued Käufe sync).
- **Dependencies:** Stripe/PayPal SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe). - **Dependencies:** Paddle SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe).
- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Stripe/PayPal Test-Accounts. - **Kosten:** Env für Sandbox/Prod-Keys; Test mit Paddle Test-Accounts.
## 12. Todo-List (Status: Alle Planung completed) ## 12. Todo-List (Status: Alle Planung completed)
- [x] Analyse. - [x] Analyse.

View File

@@ -1,7 +1,7 @@
# Billing and Payments # Billing and Payments
## Overview ## Overview
The Fotospiel platform supports multiple payment providers for package purchases: Stripe for one-time and subscription payments, and PayPal for orders and subscriptions. Billing is handled through a freemium model with endcustomer event packages and reseller subscriptions. All payments are processed via API integrations, with webhooks for asynchronous updates. The Fotospiel platform supports multiple payment providers for package purchases: Stripe for one-time and subscription payments, and Paddle for orders and subscriptions. Billing is handled through a freemium model with endcustomer event packages and reseller subscriptions. All payments are processed via API integrations, with webhooks for asynchronous updates.
## Stripe Integration ## Stripe Integration
- **One-time Payments**: Use Stripe Checkout for endcustomer event packages. Create PaymentIntent via `StripeController@createPaymentIntent`. - **One-time Payments**: Use Stripe Checkout for endcustomer event packages. Create PaymentIntent via `StripeController@createPaymentIntent`.
@@ -19,13 +19,13 @@ The Fotospiel platform supports multiple payment providers for package purchases
| `invoice.payment_failed` | Flags tenant for follow-up, sends alerts | `handleInvoicePaymentFailed` | | `invoice.payment_failed` | Flags tenant for follow-up, sends alerts | `handleInvoicePaymentFailed` |
| `customer.subscription.deleted` | Finalises cancellation/downgrade | `handleSubscriptionDeleted` | | `customer.subscription.deleted` | Finalises cancellation/downgrade | `handleSubscriptionDeleted` |
## PayPal Integration ## Paddle Integration
- **SDK**: Migrated to PayPal Server SDK v1.0+ (`paypal/paypal-server-sdk`). Uses Builder pattern for requests and Controllers for API calls. - **SDK**: Migrated to Paddle Server SDK v1.0+ (`paypal/paypal-server-sdk`). Uses Builder pattern for requests and Controllers for API calls.
- **Orders (One-time Payments)**: Endcustomer event packages via Orders API. `PayPalController@createOrder` uses `OrderRequestBuilder` with `CheckoutPaymentIntent::CAPTURE`, custom_id for metadata (tenant_id, package_id, type). Capture in `@captureOrder` using `OrdersController->captureOrder`. DB creation in `processPurchaseFromOrder`. - **Orders (One-time Payments)**: Endcustomer event packages via Orders API. `PaddleController@createOrder` uses `OrderRequestBuilder` with `CheckoutPaymentIntent::CAPTURE`, custom_id for metadata (tenant_id, package_id, type). Capture in `@captureOrder` using `OrdersController->captureOrder`. DB creation in `processPurchaseFromOrder`.
- **Subscriptions (Recurring Payments)**: Reseller subscriptions via Orders API with StoredPaymentSource for recurring setup (no dedicated SubscriptionsController in SDK). `PayPalController@createSubscription` uses `OrderRequestBuilder` with `StoredPaymentSource` (payment_initiator: CUSTOMER, payment_type: RECURRING, usage: FIRST), custom_id including plan_id. Initial order capture sets up subscription; subsequent billing via PayPal dashboard or webhooks. DB records created on initial capture, with expires_at for annual billing. - **Subscriptions (Recurring Payments)**: Reseller subscriptions via Orders API with StoredPaymentSource for recurring setup (no dedicated SubscriptionsController in SDK). `PaddleController@createSubscription` uses `OrderRequestBuilder` with `StoredPaymentSource` (payment_initiator: CUSTOMER, payment_type: RECURRING, usage: FIRST), custom_id including plan_id. Initial order capture sets up subscription; subsequent billing via Paddle dashboard or webhooks. DB records created on initial capture, with expires_at for annual billing.
- **Differences**: One-time: Standard Order with payment_type ONE_TIME (default). Recurring: Order with StoredPaymentSource RECURRING to enable future charges without new approvals. plan_id stored in metadata for reference; no separate subscription ID from SDK. - **Differences**: One-time: Standard Order with payment_type ONE_TIME (default). Recurring: Order with StoredPaymentSource RECURRING to enable future charges without new approvals. plan_id stored in metadata for reference; no separate subscription ID from SDK.
- **Client Setup**: OAuth2 Client Credentials flow. Builder: `PaypalServerSdkClientBuilder::init()->clientCredentialsAuthCredentials(ClientCredentialsAuthCredentialsBuilder::init(client_id, secret))->environment(Environment::SANDBOX/PRODUCTION)->build()`. - **Client Setup**: OAuth2 Client Credentials flow. Builder: `PaypalServerSdkClientBuilder::init()->clientCredentialsAuthCredentials(ClientCredentialsAuthCredentialsBuilder::init(client_id, secret))->environment(Environment::SANDBOX/PRODUCTION)->build()`.
- **Webhooks**: `PayPalWebhookController@verify` handles events like `PAYMENT.CAPTURE.COMPLETED` (process initial/renewal purchase), `BILLING.SUBSCRIPTION.CANCELLED` or equivalent order events (deactivate package). Simplified signature verification (TODO: Implement `VerifyWebhookSignature`). - **Webhooks**: `PaddleWebhookController@verify` handles events like `PAYMENT.CAPTURE.COMPLETED` (process initial/renewal purchase), `BILLING.SUBSCRIPTION.CANCELLED` or equivalent order events (deactivate package). Simplified signature verification (TODO: Implement `VerifyWebhookSignature`).
- **Webhook Matrix**: - **Webhook Matrix**:
| Event | Purpose | Internal handler | | Event | Purpose | Internal handler |
@@ -36,15 +36,15 @@ The Fotospiel platform supports multiple payment providers for package purchases
| `BILLING.SUBSCRIPTION.SUSPENDED` | Pauses package benefits pending review | `handleSubscription` | | `BILLING.SUBSCRIPTION.SUSPENDED` | Pauses package benefits pending review | `handleSubscription` |
- **Idempotency**: Check `provider_id` in `PackagePurchase` before creation. Transactions for DB safety. - **Idempotency**: Check `provider_id` in `PackagePurchase` before creation. Transactions for DB safety.
- **Configuration**: Keys in `config/services.php` under `paypal`. Sandbox mode via `paypal.sandbox`. - **Configuration**: Keys in `config/services.php` under `paypal`. Sandbox mode via `paypal.sandbox`.
- **Migration Notes**: Replaced old Checkout SDK (`PayPalCheckoutSdk`). Updated imports, requests (e.g., OrdersCreateRequest -> OrderRequestBuilder, Subscriptions -> StoredPaymentSource in Orders). Responses: Use `getStatusCode()` and `getResult()`. Tests mocked new structures. No breaking changes in auth or metadata handling; recurring flows now unified under Orders API. - **Migration Notes**: Replaced old Checkout SDK (`PaddleCheckoutSdk`). Updated imports, requests (e.g., OrdersCreateRequest -> OrderRequestBuilder, Subscriptions -> StoredPaymentSource in Orders). Responses: Use `getStatusCode()` and `getResult()`. Tests mocked new structures. No breaking changes in auth or metadata handling; recurring flows now unified under Orders API.
## Database Models ## Database Models
- **PackagePurchase**: Records purchases with `tenant_id`, `package_id`, `provider_id` (Stripe PI/PayPal Order ID), `price`, `type` (endcustomer_event/reseller_subscription), `status` (completed/refunded), `metadata` (JSON with provider details). - **PackagePurchase**: Records purchases with `tenant_id`, `package_id`, `provider_id` (Stripe PI/Paddle Order ID), `price`, `type` (endcustomer_event/reseller_subscription), `status` (completed/refunded), `metadata` (JSON with provider details).
- **TenantPackage**: Active packages with `tenant_id`, `package_id`, `price`, `purchased_at`, `expires_at`, `active` flag. Updated on purchase/cancellation. - **TenantPackage**: Active packages with `tenant_id`, `package_id`, `price`, `purchased_at`, `expires_at`, `active` flag. Updated on purchase/cancellation.
- **Constraints**: `type` CHECK (endcustomer_event, reseller_subscription), `price` NOT NULL. - **Constraints**: `type` CHECK (endcustomer_event, reseller_subscription), `price` NOT NULL.
## Flows ## Flows
1. **Purchase Initiation**: User selects package in PurchaseWizard. For free: direct assignment. Paid: Redirect to provider (Stripe Checkout or PayPal approve link). 1. **Purchase Initiation**: User selects package in PurchaseWizard. For free: direct assignment. Paid: Redirect to provider (Stripe Checkout or Paddle approve link).
2. **Completion**: Provider callback/webhook triggers capture/confirmation. Create `PackagePurchase` and `TenantPackage`. Update tenant `subscription_status` to 'active'. 2. **Completion**: Provider callback/webhook triggers capture/confirmation. Create `PackagePurchase` and `TenantPackage`. Update tenant `subscription_status` to 'active'.
3. **Cancellation/Refund**: Webhook updates status to 'cancelled', deactivates `TenantPackage`. 3. **Cancellation/Refund**: Webhook updates status to 'cancelled', deactivates `TenantPackage`.
4. **Trial**: First reseller subscription gets 14-day trial (`expires_at = now() + 14 days`). 4. **Trial**: First reseller subscription gets 14-day trial (`expires_at = now() + 14 days`).
@@ -63,5 +63,5 @@ The Fotospiel platform supports multiple payment providers for package purchases
## Security & Compliance ## Security & Compliance
- GDPR: No PII in logs/metadata beyond necessary (tenant_id anonymous). - GDPR: No PII in logs/metadata beyond necessary (tenant_id anonymous).
- Auth: Sanctum tokens for API, CSRF for web. - Auth: Sanctum tokens for API, CSRF for web.
- Webhooks: IP whitelisting (PayPal IPs), signature verification. - Webhooks: IP whitelisting (Paddle IPs), signature verification.
- Retention: Purchases retained per Privacy policy; update on changes. - Retention: Purchases retained per Privacy policy; update on changes.

View File

@@ -2,16 +2,16 @@
## Goals ## Goals
- Replace the legacy marketing checkout flow with a single `CheckoutController` that owns auth, payment, and confirmation steps. - Replace the legacy marketing checkout flow with a single `CheckoutController` that owns auth, payment, and confirmation steps.
- Support Stripe card payments and PayPal orders with a consistent state machine that can be extended to other providers. - Support Stripe card payments and Paddle orders with a consistent state machine that can be extended to other providers.
- Keep package activation logic idempotent and traceable while respecting GDPR (no new PII logging, no leaked tokens). - Keep package activation logic idempotent and traceable while respecting GDPR (no new PII logging, no leaked tokens).
- Prepare the frontend wizard to drive the flow as an SPA without relying on server-side redirects. - Prepare the frontend wizard to drive the flow as an SPA without relying on server-side redirects.
## Core Building Blocks ## Core Building Blocks
- **CheckoutSession model/table** keeps one purchase attempt per user + package. It stores provider choice, status, pricing snapshot, and external ids (Stripe intent, PayPal order, etc.). - **CheckoutSession model/table** keeps one purchase attempt per user + package. It stores provider choice, status, pricing snapshot, and external ids (Stripe intent, Paddle order, etc.).
- **CheckoutPaymentService** orchestrates provider-specific actions (create intent/order, capture, sync metadata) and normalises responses for the wizard. - **CheckoutPaymentService** orchestrates provider-specific actions (create intent/order, capture, sync metadata) and normalises responses for the wizard.
- **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds. - **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds.
- **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA. - **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA.
- **Webhooks** (Stripe, PayPal) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths. - **Webhooks** (Stripe, Paddle) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths.
- **Feature Flag**: `config/checkout.php` exposes `CHECKOUT_WIZARD_ENABLED` and `CHECKOUT_WIZARD_FLAG` so the SPA flow can be toggled or gradual-rolled out during launch. - **Feature Flag**: `config/checkout.php` exposes `CHECKOUT_WIZARD_ENABLED` and `CHECKOUT_WIZARD_FLAG` so the SPA flow can be toggled or gradual-rolled out during launch.
- **Operational**: Rotate JWT signing keys with `php artisan oauth:rotate-keys` (updates key folder per KID; remember to bump `OAUTH_JWT_KID`). - **Operational**: Rotate JWT signing keys with `php artisan oauth:rotate-keys` (updates key folder per KID; remember to bump `OAUTH_JWT_KID`).
@@ -22,7 +22,7 @@ State constants live on `CheckoutSession` (`status` column, enum):
| --- | --- | --- | | --- | --- | --- |
| `draft` | Session created, package locked in, no provider chosen. | `awaiting_payment_method`, `completed` (free), `cancelled` | | `draft` | Session created, package locked in, no provider chosen. | `awaiting_payment_method`, `completed` (free), `cancelled` |
| `awaiting_payment_method` | Paid package; provider picked, waiting for client to initialise SDK. | `requires_customer_action`, `processing`, `cancelled` | | `awaiting_payment_method` | Paid package; provider picked, waiting for client to initialise SDK. | `requires_customer_action`, `processing`, `cancelled` |
| `requires_customer_action` | Stripe 3DS, PayPal approval window open, or additional customer steps needed. | `processing`, `failed`, `cancelled` | | `requires_customer_action` | Stripe 3DS, Paddle approval window open, or additional customer steps needed. | `processing`, `failed`, `cancelled` |
| `processing` | Provider reported success, backend validating / capturing / assigning. | `completed`, `failed` | | `processing` | Provider reported success, backend validating / capturing / assigning. | `completed`, `failed` |
| `completed` | Checkout finished, package assigned, confirmation step unblocked. | none | | `completed` | Checkout finished, package assigned, confirmation step unblocked. | none |
| `failed` | Provider declined or capture check failed; retain reason. | `awaiting_payment_method` (retry), `cancelled` | | `failed` | Provider declined or capture check failed; retain reason. | `awaiting_payment_method` (retry), `cancelled` |
@@ -49,34 +49,34 @@ Create Eloquent model `App\Models\CheckoutSession` with casts for JSON columns a
Group under `web.php` with `middleware(['auth', 'verified', 'locale', 'throttle:checkout'])`: Group under `web.php` with `middleware(['auth', 'verified', 'locale', 'throttle:checkout'])`:
- `POST /checkout/session` (`CheckoutController@storeSession`): create or resume active session for selected package, return `{id, status, amount, package_snapshot}`. - `POST /checkout/session` (`CheckoutController@storeSession`): create or resume active session for selected package, return `{id, status, amount, package_snapshot}`.
- `PATCH /checkout/session/{session}/package` (`updatePackage`): allow switching package before payment; resets provider-specific fields and status to `draft`. - `PATCH /checkout/session/{session}/package` (`updatePackage`): allow switching package before payment; resets provider-specific fields and status to `draft`.
- `POST /checkout/session/{session}/provider` (`selectProvider`): set provider (`stripe` or `paypal`), transitions to `awaiting_payment_method` and returns provider configuration (publishable key, PayPal client id, feature flags). - `POST /checkout/session/{session}/provider` (`selectProvider`): set provider (`stripe` or `paypal`), transitions to `awaiting_payment_method` and returns provider configuration (publishable key, Paddle client id, feature flags).
- `POST /checkout/session/{session}/stripe-intent` (`createStripeIntent`): idempotently create/update PaymentIntent with metadata (user, tenant, package, session id) and deliver `{client_secret, intent_id}`. - `POST /checkout/session/{session}/stripe-intent` (`createStripeIntent`): idempotently create/update PaymentIntent with metadata (user, tenant, package, session id) and deliver `{client_secret, intent_id}`.
- `POST /checkout/session/{session}/stripe/confirm` (`confirmStripeIntent`): server-side verify PaymentIntent status (retrieve from Stripe) and transition to `processing` when `succeeded` or `requires_action`. - `POST /checkout/session/{session}/stripe/confirm` (`confirmStripeIntent`): server-side verify PaymentIntent status (retrieve from Stripe) and transition to `processing` when `succeeded` or `requires_action`.
- `POST /checkout/session/{session}/paypal/order` (`createPayPalOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`. - `POST /checkout/session/{session}/paypal/order` (`createPaddleOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`.
- `POST /checkout/session/{session}/paypal/capture` (`capturePayPalOrder`): capture order server-side, transition to `processing` if status `COMPLETED`. - `POST /checkout/session/{session}/paypal/capture` (`capturePaddleOrder`): capture order server-side, transition to `processing` if status `COMPLETED`.
- `POST /checkout/session/{session}/free` (`activateFreePackage`): bypass providers, run assignment service, mark `completed`. - `POST /checkout/session/{session}/free` (`activateFreePackage`): bypass providers, run assignment service, mark `completed`.
- `POST /checkout/session/{session}/complete` (`finalise`): provider-agnostic finishing hook used after `processing` to run `CheckoutAssignmentService`, persist `PackagePurchase`, queue mails, and respond with summary. - `POST /checkout/session/{session}/complete` (`finalise`): provider-agnostic finishing hook used after `processing` to run `CheckoutAssignmentService`, persist `PackagePurchase`, queue mails, and respond with summary.
- `GET /checkout/session/{session}` (`show`): used by wizard polling to keep state in sync (status, provider display data, failure reasons). - `GET /checkout/session/{session}` (`show`): used by wizard polling to keep state in sync (status, provider display data, failure reasons).
- `DELETE /checkout/session/{session}` (`cancel`): expire session, clean provider artefacts (cancel intent/order if applicable). - `DELETE /checkout/session/{session}` (`cancel`): expire session, clean provider artefacts (cancel intent/order if applicable).
Stripe/PayPal routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified. Paddle routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified.
### Services & Jobs ### Services & Jobs
- `CheckoutSessionService`: create/resume session, guard transitions, enforce TTL, and wrap DB transactions. - `CheckoutSessionService`: create/resume session, guard transitions, enforce TTL, and wrap DB transactions.
- `CheckoutPaymentService`: entry point with methods `initialiseStripe`, `confirmStripe`, `initialisePayPal`, `capturePayPal`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, PayPal SDK) and persists external ids. - `CheckoutPaymentService`: entry point with methods `initialiseStripe`, `confirmStripe`, `initialisePaddle`, `capturePaddle`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, Paddle SDK) and persists external ids.
- `CheckoutAssignmentService`: generates or reuses tenant, writes `TenantPackage`, `PackagePurchase`, updates user role/status, dispatches `Welcome` + purchase receipts, and emits domain events (`CheckoutCompleted`). - `CheckoutAssignmentService`: generates or reuses tenant, writes `TenantPackage`, `PackagePurchase`, updates user role/status, dispatches `Welcome` + purchase receipts, and emits domain events (`CheckoutCompleted`).
- `SyncCheckoutFromWebhook` job: invoked by webhook controllers with provider payload, looks up `CheckoutSession` via provider id, runs assignment if needed, records failure states. - `SyncCheckoutFromWebhook` job: invoked by webhook controllers with provider payload, looks up `CheckoutSession` via provider id, runs assignment if needed, records failure states.
### Webhook Alignment ### Webhook Alignment
- Update `StripeWebhookController` to resolve `CheckoutSession::where('stripe_payment_intent_id', intentId)`; when event indicates success, transition to `processing` (if not already), enqueue `SyncCheckoutFromWebhook` to finish assignment, and mark `completed` once done. - Update `StripeWebhookController` to resolve `CheckoutSession::where('stripe_payment_intent_id', intentId)`; when event indicates success, transition to `processing` (if not already), enqueue `SyncCheckoutFromWebhook` to finish assignment, and mark `completed` once done.
- Update `PayPalWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`. - Update `PaddleWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`.
- Webhooks become source-of-truth for delayed confirmations; wizard polls `GET /checkout/session/{id}` until `completed`. - Webhooks become source-of-truth for delayed confirmations; wizard polls `GET /checkout/session/{id}` until `completed`.
### Validation & Security ### Validation & Security
- All mutating routes use CSRF tokens and `auth` guard (session-based). Add `EnsureCheckoutSessionOwner` middleware enforcing that the session belongs to `request->user()`. - All mutating routes use CSRF tokens and `auth` guard (session-based). Add `EnsureCheckoutSessionOwner` middleware enforcing that the session belongs to `request->user()`.
- Input validation via dedicated Form Request classes (e.g., `StoreCheckoutSessionRequest`, `StripeIntentRequest`). - Input validation via dedicated Form Request classes (e.g., `StoreCheckoutSessionRequest`, `StripeIntentRequest`).
- Provider responses are never logged raw; only store ids + safe metadata. - Provider responses are never logged raw; only store ids + safe metadata.
- Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and PayPal orders. - Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and Paddle orders.
## Frontend Touchpoints ## Frontend Touchpoints
@@ -89,7 +89,7 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
- On mount (and whenever package changes), call `/checkout/session` to create/resume session. Reset state if API reports new id. - On mount (and whenever package changes), call `/checkout/session` to create/resume session. Reset state if API reports new id.
- Provider tabs call `selectProvider`. Stripe tab loads Stripe.js dynamically (import from `@stripe/stripe-js`) and mounts Elements once `client_secret` arrives. - Provider tabs call `selectProvider`. Stripe tab loads Stripe.js dynamically (import from `@stripe/stripe-js`) and mounts Elements once `client_secret` arrives.
- Stripe flow: submit button triggers `stripe.confirmCardPayment(clientSecret)`, handle `requires_action`, then POST `/checkout/session/{id}/stripe/confirm`. On success, call `/checkout/session/{id}/complete` and advance to confirmation step. - Stripe flow: submit button triggers `stripe.confirmCardPayment(clientSecret)`, handle `requires_action`, then POST `/checkout/session/{id}/stripe/confirm`. On success, call `/checkout/session/{id}/complete` and advance to confirmation step.
- PayPal flow: render PayPal Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`. - Paddle flow: render Paddle Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`.
- Free packages skip provider selection; call `/checkout/session/{id}/free` and immediately advance. - Free packages skip provider selection; call `/checkout/session/{id}/free` and immediately advance.
- Display status toasts based on `paymentStatus`; show inline error block when `failed` with `failure_reason` from API. - Display status toasts based on `paymentStatus`; show inline error block when `failed` with `failure_reason` from API.
@@ -106,10 +106,10 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
5. Once Stripe marks intent `succeeded`, backend transitions to `processing`, calls `CheckoutAssignmentService`, and marks `completed`. 5. Once Stripe marks intent `succeeded`, backend transitions to `processing`, calls `CheckoutAssignmentService`, and marks `completed`.
6. For reseller packages, Stripe subscription is created after assignment using configured price ids; resulting subscription id stored on session + tenant record. 6. For reseller packages, Stripe subscription is created after assignment using configured price ids; resulting subscription id stored on session + tenant record.
### PayPal (one-off and subscription) ### Paddle (one-off and subscription)
1. Session `draft` -> provider `paypal`. 1. Session `draft` -> provider `paypal`.
2. `createPayPalOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type. 2. `createPaddleOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type.
3. After approval, `capturePayPalOrder` verifies capture status; on `COMPLETED`, transitions to `processing`. 3. After approval, `capturePaddleOrder` verifies capture status; on `COMPLETED`, transitions to `processing`.
4. Assignment service runs, storing order id as `provider_id`. For subscriptions, capture handler stores subscription id and updates tenant subscription status. 4. Assignment service runs, storing order id as `provider_id`. For subscriptions, capture handler stores subscription id and updates tenant subscription status.
5. Webhooks handle late captures or cancellations (updates session -> `failed` or `cancelled`). 5. Webhooks handle late captures or cancellations (updates session -> `failed` or `cancelled`).
@@ -118,18 +118,18 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
## Migration Strategy ## Migration Strategy
1. **Phase 1** (current): land schema, services, new API routes; keep legacy MarketingController flow for fallback. 1. **Phase 1** (current): land schema, services, new API routes; keep legacy MarketingController flow for fallback.
2. **Phase 2**: wire the new wizard PaymentStep behind feature flag `checkout_v2` (in `.env` / config). Run internal QA with Stripe/PayPal sandbox. 2. **Phase 2**: wire the new wizard PaymentStep behind feature flag `checkout_v2` (in `.env` / config). Run internal QA with Paddle sandbox.
3. **Phase 3**: enable feature flag for production tenants, monitor Stripe/PayPal events, then delete legacy marketing payment paths and routes. 3. **Phase 3**: enable feature flag for production tenants, monitor Paddle events, then delete legacy marketing payment paths and routes.
4. **Phase 4**: tighten webhook logic and remove `MarketingController::checkout`, `::paypalCheckout`, `::stripeSubscription` once new flow is stable. 4. **Phase 4**: tighten webhook logic and remove `MarketingController::checkout`, `::paypalCheckout`, `::stripeSubscription` once new flow is stable.
## Testing & QA ## Testing & QA
- **Feature tests**: JSON endpoints for session lifecycle (create, provider select, intent creation, capture success/failure, free activation). Include multi-locale assertions. - **Feature tests**: JSON endpoints for session lifecycle (create, provider select, intent creation, capture success/failure, free activation). Include multi-locale assertions.
- **Payment integration tests**: use Stripe + PayPal SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves. - **Payment integration tests**: use Stripe + Paddle SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves.
- **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, PayPal approval, failure retry, free package shortcut, session resume after refresh. - **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, Paddle approval, failure retry, free package shortcut, session resume after refresh.
- **Webhooks**: unit tests for mapping provider ids to sessions, plus job tests for idempotent assignment. - **Webhooks**: unit tests for mapping provider ids to sessions, plus job tests for idempotent assignment.
- **Scheduler**: test `checkout:expire-sessions` to confirm PaymentIntents are cancelled and sessions flagged `cancelled`. - **Scheduler**: test `checkout:expire-sessions` to confirm PaymentIntents are cancelled and sessions flagged `cancelled`.
## Open Questions / Follow-Ups ## Open Questions / Follow-Ups
- Map package records to Stripe price ids and PayPal plan ids (store on `packages` table or config?). - Map package records to Stripe price ids and Paddle plan ids (store on `packages` table or config?).
- Confirm legal copy updates for new checkout experience before GA. - Confirm legal copy updates for new checkout experience before GA.
- Align email templates (welcome, receipt) with new assignment service outputs. - Align email templates (welcome, receipt) with new assignment service outputs.

View File

@@ -13,7 +13,7 @@ Die App ersetzt das frühere Filament-basierte Tenant-Panel und fokussiert auf e
## Aktuelle Highlights (Q4 2025) ## Aktuelle Highlights (Q4 2025)
- **Geführtes Onboarding**: `/event-admin/welcome/*` orchestriert den Welcome Flow (Hero → How-It-Works → Paketwahl → Zusammenfassung → Event Setup). Guarding erfolgt über `onboarding_completed_at`. - **Geführtes Onboarding**: `/event-admin/welcome/*` orchestriert den Welcome Flow (Hero → How-It-Works → Paketwahl → Zusammenfassung → Event Setup). Guarding erfolgt über `onboarding_completed_at`.
- **Direkter Checkout**: Stripe & PayPal sind in die Paketwahl des Welcome Flows eingebettet; Fortschritt wird im Onboarding-Context persistiert. - **Direkter Checkout**: Paddle sind in die Paketwahl des Welcome Flows eingebettet; Fortschritt wird im Onboarding-Context persistiert.
- **Filament Wizard**: Für Super-Admins existiert ein paralleler QR/Join-Token-Wizard in Filament (Token-Generierung, Layout-Downloads, Rotation). - **Filament Wizard**: Für Super-Admins existiert ein paralleler QR/Join-Token-Wizard in Filament (Token-Generierung, Layout-Downloads, Rotation).
- **Join Tokens only**: Gäste erhalten ausschließlich join-token-basierte Links/QRs; slug-basierte URLs wurden deaktiviert. QR-Drucklayouts liegen unter `resources/views/pdf/join-tokens/*`. - **Join Tokens only**: Gäste erhalten ausschließlich join-token-basierte Links/QRs; slug-basierte URLs wurden deaktiviert. QR-Drucklayouts liegen unter `resources/views/pdf/join-tokens/*`.
- **OAuth Alignment**: `VITE_OAUTH_CLIENT_ID` + `/event-admin/auth/callback` werden seedingseitig synchron gehalten; siehe `docs/prp/tenant-app-specs/api-usage.md`. - **OAuth Alignment**: `VITE_OAUTH_CLIENT_ID` + `/event-admin/auth/callback` werden seedingseitig synchron gehalten; siehe `docs/prp/tenant-app-specs/api-usage.md`.

View File

@@ -11,7 +11,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
- **Galerie-Management**: Upload, Moderation, Feature-Flags, Analytics. - **Galerie-Management**: Upload, Moderation, Feature-Flags, Analytics.
- **Mitglieder-Verwaltung**: Einladungen, Rollen, Zugriffskontrolle. - **Mitglieder-Verwaltung**: Einladungen, Rollen, Zugriffskontrolle.
- **Tasks & Emotions**: Bibliothek, Zuweisung, Fortschritts-Tracking. - **Tasks & Emotions**: Bibliothek, Zuweisung, Fortschritts-Tracking.
- **Abrechnung**: Paketübersicht, Stripe/PayPal Checkout, Ledger. - **Abrechnung**: Paketübersicht, Paddle Checkout, Ledger.
- **Einstellungen**: Branding, Limits, Rechtstexte, Benachrichtigungen. - **Einstellungen**: Branding, Limits, Rechtstexte, Benachrichtigungen.
- **Offline-Support**: App-Shell-Caching, Queueing von Mutationen, Sync bei Reconnect. - **Offline-Support**: App-Shell-Caching, Queueing von Mutationen, Sync bei Reconnect.
- **Compliance**: Audit-Logging, GDPR-konforme Löschung, ETag-basierte Konfliktlösung. - **Compliance**: Audit-Logging, GDPR-konforme Löschung, ETag-basierte Konfliktlösung.
@@ -25,7 +25,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
- Routen `/event-admin/welcome/*` bilden den Flow. - Routen `/event-admin/welcome/*` bilden den Flow.
- Filament stellt einen korrespondierenden Onboarding-Wizard (QR/Join-Token, Layout-Download) bereit; Abschluss setzt `onboarding_completed_at` serverseitig. - Filament stellt einen korrespondierenden Onboarding-Wizard (QR/Join-Token, Layout-Download) bereit; Abschluss setzt `onboarding_completed_at` serverseitig.
- `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`). - `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`).
- Paketwahl nutzt `GET /tenant/packages`; Stripe/PayPal-Fallbacks informieren bei fehlender Konfiguration. - Paketwahl nutzt `GET /tenant/packages`; Paddle-Fallbacks informieren bei fehlender Konfiguration.
- Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde. - Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde.
### Event Lifecycle ### Event Lifecycle
@@ -45,7 +45,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
### Billing & Checkout ### Billing & Checkout
- Pakete + Credit-Balance anzeigen. - Pakete + Credit-Balance anzeigen.
- Stripe PaymentIntent & PayPal Smart Buttons; Fallback-Meldung bei fehlender Konfiguration. - Stripe PaymentIntent & Paddle Smart Buttons; Fallback-Meldung bei fehlender Konfiguration.
- Ledger mit Historie (Paginierung, Filter). - Ledger mit Historie (Paginierung, Filter).
### Settings ### Settings
@@ -83,7 +83,7 @@ Die App nutzt Endpunkte aus `docs/prp/03-api.md`.
## Teststrategie ## Teststrategie
- **PHPUnit**: Feature-Tests für Auth-Guards (Tenant ohne Events → Welcome Flow). - **PHPUnit**: Feature-Tests für Auth-Guards (Tenant ohne Events → Welcome Flow).
- **React Testing Library**: `TenantWelcomeLayout`, `PackageSelection`, `OnboardingGuard`, `OrderSummary`. - **React Testing Library**: `TenantWelcomeLayout`, `PackageSelection`, `OnboardingGuard`, `OrderSummary`.
- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` deckt Login, Welcome → Packages → Summary → Event Setup ab; Erweiterung um Stripe/PayPal Happy Paths und Offline/Retry geplant. - **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` deckt Login, Welcome → Packages → Summary → Event Setup ab; Erweiterung um Paddle Happy Paths und Offline/Retry geplant.
- **Smoke Tests**: `npm run test:e2e` in CI mit optionalen Credentials (`E2E_TENANT_EMAIL`, `E2E_TENANT_PASSWORD`, Stripe/PayPal Keys). - **Smoke Tests**: `npm run test:e2e` in CI mit optionalen Credentials (`E2E_TENANT_EMAIL`, `E2E_TENANT_PASSWORD`, Paddle Keys).
Für UI-Details siehe `docs/prp/tenant-app-specs/pages-ui.md`. Einstellungen werden in `docs/prp/tenant-app-specs/settings-config.md` beschrieben. Für UI-Details siehe `docs/prp/tenant-app-specs/pages-ui.md`. Einstellungen werden in `docs/prp/tenant-app-specs/settings-config.md` beschrieben.

View File

@@ -18,8 +18,8 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Hero | `/event-admin/welcome` | `TenantWelcomeLayout`, `WelcomeStepCard`, `EmblaCarousel` | CTA „Pakete entdecken“, sekundärer Link „Später entscheiden“ | | Hero | `/event-admin/welcome` | `TenantWelcomeLayout`, `WelcomeStepCard`, `EmblaCarousel` | CTA „Pakete entdecken“, sekundärer Link „Später entscheiden“ |
| How It Works | `/event-admin/welcome` (Carousel Slide) | Icon Cards, Animated Gradients | 3 Vorteile (Fotos festhalten, Aufgaben, Gäste aktivieren) | | How It Works | `/event-admin/welcome` (Carousel Slide) | Icon Cards, Animated Gradients | 3 Vorteile (Fotos festhalten, Aufgaben, Gäste aktivieren) |
| Paketwahl | `/event-admin/welcome/packages` | `PackageCard`, `PricingToggle`, `QueryPackageList` | Stripe/PayPal Pricing, Feature-Badges, Auswahl persistiert im Onboarding-Context | | Paketwahl | `/event-admin/welcome/packages` | `PackageCard`, `PricingToggle`, `QueryPackageList` | Paddle Pricing, Feature-Badges, Auswahl persistiert im Onboarding-Context |
| Zusammenfassung | `/event-admin/welcome/summary` | `OrderSummaryCard`, Stripe Elements, PayPal Buttons | Hinweise bei fehlender Zahlungs-Konfiguration, CTA „Weiter zum Setup“ | | Zusammenfassung | `/event-admin/welcome/summary` | `OrderSummaryCard`, Stripe Elements, Paddle Buttons | Hinweise bei fehlender Zahlungs-Konfiguration, CTA „Weiter zum Setup“ |
| Event Setup | `/event-admin/welcome/event` | `FirstEventForm`, `FormStepper`, Toasts | Formular (Name, Datum, Sprache, Feature-Toggles) + Abschluss CTA „Event erstellen“ | | Event Setup | `/event-admin/welcome/event` | `FirstEventForm`, `FormStepper`, Toasts | Formular (Name, Datum, Sprache, Feature-Toggles) + Abschluss CTA „Event erstellen“ |
### Guards & Fortschritt ### Guards & Fortschritt
@@ -39,7 +39,7 @@
- **Fotos**: Moderationsgrid (Masonry), Filter (Neu, Genehmigt, Featured), Bulk-Aktionen in Sticky-Footer. - **Fotos**: Moderationsgrid (Masonry), Filter (Neu, Genehmigt, Featured), Bulk-Aktionen in Sticky-Footer.
- **Tasks**: Tabs (Bibliothek, Zuweisungen), Drag-and-Drop (React Beautiful DnD), Inline-Editor für Aufgaben. - **Tasks**: Tabs (Bibliothek, Zuweisungen), Drag-and-Drop (React Beautiful DnD), Inline-Editor für Aufgaben.
- **Einstellungen**: Accordion-Struktur (Branding, Legal Pages, Benachrichtigungen, Abrechnung). Preview-Panel für Farben und Logos. - **Einstellungen**: Accordion-Struktur (Branding, Legal Pages, Benachrichtigungen, Abrechnung). Preview-Panel für Farben und Logos.
- **Abrechnung**: Kreditübersicht, Kauflog (infinite-scroll), Zahlungsoptionen (Stripe Karte, PayPal Checkout). - **Abrechnung**: Kreditübersicht, Kauflog (infinite-scroll), Zahlungsoptionen (Stripe Karte, Paddle Checkout).
## Informationsarchitektur (aktuelle React-Router-Konfiguration) ## Informationsarchitektur (aktuelle React-Router-Konfiguration)
``` ```
@@ -61,7 +61,7 @@
## Testabdeckung (UI) ## Testabdeckung (UI)
- **Jest/RTL**: `TenantWelcomeLayout`, `WelcomeStepCard`, `PackageSelection`, `OnboardingGuard`. - **Jest/RTL**: `TenantWelcomeLayout`, `WelcomeStepCard`, `PackageSelection`, `OnboardingGuard`.
- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` (Login Guard, Welcome → Packages → Summary → Event Setup). Erweiterbar um Stripe/PayPal-Happy-Path sowie Offline-/Retry-Szenarien. - **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` (Login Guard, Welcome → Packages → Summary → Event Setup). Erweiterbar um Paddle-Happy-Path sowie Offline-/Retry-Szenarien.
## Legacy-Referenz (Framework7 Entwurf 2025-09) ## Legacy-Referenz (Framework7 Entwurf 2025-09)
Die ursprünglichen Wireframes für Framework7 (Toolbar, FAB, Infinite Scroll) sind weiterhin im Repo historisiert (`docs/prp/tenant-app-specs/pages-ui-legacy.md`). Für Vergleiche bei Regressionen oder Migrationen bitte dort nachsehen. Die ursprünglichen Wireframes für Framework7 (Toolbar, FAB, Infinite Scroll) sind weiterhin im Repo historisiert (`docs/prp/tenant-app-specs/pages-ui-legacy.md`). Für Vergleiche bei Regressionen oder Migrationen bitte dort nachsehen.

View File

@@ -52,6 +52,6 @@
</text> </text>
</g> </g>
<text x="80" y="600" font-size="20" font-family="Inter, Arial, sans-serif" fill="#475569"> <text x="80" y="600" font-size="20" font-family="Inter, Arial, sans-serif" fill="#475569">
Stripe & PayPal Widgets erscheinen unterhalb der Karten, sobald Keys konfiguriert sind. Stripe & Paddle Widgets erscheinen unterhalb der Karten, sobald Keys konfiguriert sind.
</text> </text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -9,7 +9,7 @@
Paket: Pro 3 Events, 1000 Uploads Paket: Pro 3 Events, 1000 Uploads
</text> </text>
<text x="40" y="180" font-size="22" font-family="Inter, Arial, sans-serif" fill="#cbd5f5"> <text x="40" y="180" font-size="22" font-family="Inter, Arial, sans-serif" fill="#cbd5f5">
Zahlungsart: Stripe oder PayPal Zahlungsart: Stripe oder Paddle
</text> </text>
<line x1="40" y1="220" x2="480" y2="220" stroke="#1f2a3d" stroke-width="2"/> <line x1="40" y1="220" x2="480" y2="220" stroke="#1f2a3d" stroke-width="2"/>
<text x="40" y="280" font-size="24" font-family="Inter, Arial, sans-serif" fill="#60a5fa"> <text x="40" y="280" font-size="24" font-family="Inter, Arial, sans-serif" fill="#60a5fa">
@@ -44,10 +44,10 @@
<g transform="translate(660,380)"> <g transform="translate(660,380)">
<rect width="480" height="220" rx="24" fill="#ffffff"/> <rect width="480" height="220" rx="24" fill="#ffffff"/>
<text x="40" y="80" font-size="28" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#0f172a"> <text x="40" y="80" font-size="28" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#0f172a">
PayPal Smart Buttons Paddle Checkout Links
</text> </text>
<text x="40" y="130" font-size="20" font-family="Inter, Arial, sans-serif" fill="#1f2937"> <text x="40" y="130" font-size="20" font-family="Inter, Arial, sans-serif" fill="#1f2937">
Automatische Darstellung abhängig vom PayPal Client ID. Automatische Darstellung abhängig von der Paddle-Konfiguration.
</text> </text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -4,7 +4,7 @@
| --- | --- | | --- | --- |
| `01-welcome-hero.svg` | Hero-Screen mit CTA „Pakete entdecken“. | | `01-welcome-hero.svg` | Hero-Screen mit CTA „Pakete entdecken“. |
| `02-how-it-works.svg` | Drei Highlight-Karten (Fotos, Aufgaben, Gäste). | | `02-how-it-works.svg` | Drei Highlight-Karten (Fotos, Aufgaben, Gäste). |
| `03-package-selection.svg` | Paketübersicht inkl. Stripe/PayPal Modulen. | | `03-package-selection.svg` | Paketübersicht inkl. Paddle Modulen. |
| `04-order-summary.svg` | Zusammenfassung mit Zahlungsoptionen. | | `04-order-summary.svg` | Zusammenfassung mit Zahlungsoptionen. |
| `05-event-setup.svg` | Formular für das erste Event. | | `05-event-setup.svg` | Formular für das erste Event. |

View File

@@ -0,0 +1,28 @@
# Paddle Catalog Sync Rollout
- [x] **Schema Prep**
- [x] Add migration for `paddle_sync_status`, `paddle_synced_at`, and `paddle_snapshot` JSON on `packages`.
- [x] Update `Package` model casts/fillable + ensure factory coverage.
- [ ] **Service Layer**
- [x] Scaffold `PaddleCatalogService` (product/price CRUD, custom data mapping).
- [ ] Add DTO helpers for Paddle product/price responses.
- [ ] Extend `PaddleClient` tests/mocks for catalog endpoints.
- [x] **Sync Logic**
- [x] Implement `SyncPackageToPaddle` job with create/update flows and metadata diffing.
- [x] Create `PaddlePackagePull` job for optional remote-to-local reconciliation.
- [x] Add `paddle:sync-packages` artisan command (`--dry-run`, `--package=`, `--pull`).
- [ ] **Admin UX**
- [x] Enhance Filament PackageResource with sync status badges + last sync timestamp.
- [ ] Add table/detail actions (“Sync to Paddle”, “Link existing Paddle entity”).
- [ ] Surface last error/log context in the admin sidebar panel.
- [ ] **Ops & Observability**
- [ ] Configure dedicated log channel/Slack hook for catalog sync outcomes.
- [ ] Document failure recovery playbook (retry, unlink, support escalation).
- [ ] **Testing & QA**
- [x] Unit tests for service + jobs using mocked Paddle API.
- [x] Feature test covering artisan command flow.
- [ ] Playwright smoke to confirm admin sync action displays status.
- [ ] **Rollout Checklist**
- [ ] Seed Paddle sandbox catalog via MCP server using migrated data.
- [ ] Verify legacy packages mapped to Paddle IDs before enabling auto-sync.
- [ ] Announce workflow change to admin users (release notes + docs update).

View File

@@ -0,0 +1,14 @@
# Paddle Billing Migration
- [x] Review current billing implementation (Stripe, Paddle, RevenueCat) across code, jobs, webhooks, docs.
- [x] Design Paddle data mappings for packages ↔ products/prices, including required metadata round-trip.
- [ ] Extend Laravel config/env handling for Paddle keys, webhook secrets, feature flags (sandbox + production).
- [ ] Build Paddle API service layer and register sandbox webhooks; document endpoints/events consumed.
- [ ] Add admin catalog sync UI for packages (create/update in Paddle, display sync status, store Paddle IDs).
- [ ] Implement tenant ↔ Paddle customer synchronization and related webhook handlers.
- [x] Replace marketing checkout payment step with Paddle-hosted checkout flow and success callbacks.
- [ ] Update tenant admin billing pages to read Paddle subscription/transaction data and manage plans.
- [ ] Define mobile/native billing strategy (RevenueCat vs Paddle) and align app logic.
- [ ] Add automated tests for Paddle integration (unit, feature, e2e) covering checkout, webhooks, sync.
- [ ] Populate Paddle sandbox catalog via MCP server and validate end-to-end activation flow.
- [ ] Draft production cutover procedure (catalog creation, flag switch, legacy shutdown, monitoring, rollback).

View File

@@ -43,7 +43,7 @@ Raise the baseline security posture across guest APIs, checkout, media storage,
- `SEC-MS-04` — Storage health widget in Super Admin (Week 4). - `SEC-MS-04` — Storage health widget in Super Admin (Week 4).
5. **Payments & Webhooks (Billing)** 5. **Payments & Webhooks (Billing)**
- Link Stripe/PayPal webhooks to checkout sessions with idempotency locks. - Link Paddle webhooks to checkout sessions with idempotency locks.
- Add signature freshness validation + retry policies for provider outages. - Add signature freshness validation + retry policies for provider outages.
- Pipe failed capture events into credit ledger audits and operator alerts. - Pipe failed capture events into credit ledger audits and operator alerts.
- **Tickets** - **Tickets**

View File

@@ -32,11 +32,11 @@ Owner: Codex (handoff)
- [x] Review PWA manifest/offline setup so die kombinierte Welcome+Management-Experience TWA-/Capacitor-ready ist (Manifest + `admin-sw.js` dokumentiert). - [x] Review PWA manifest/offline setup so die kombinierte Welcome+Management-Experience TWA-/Capacitor-ready ist (Manifest + `admin-sw.js` dokumentiert).
- [x] Extend docs: PRP-Onboarding-Abschnitte aktualisiert, Screenshots unter `docs/screenshots/tenant-admin-onboarding/` ergänzt, Testscope notiert. - [x] Extend docs: PRP-Onboarding-Abschnitte aktualisiert, Screenshots unter `docs/screenshots/tenant-admin-onboarding/` ergänzt, Testscope notiert.
- [x] Add automated coverage: Vitest + Testing Library für Welcome Landing, Dashboard-Guard und Checkout-Komponenten; `npm run test:unit` führt Suite aus. - [x] Add automated coverage: Vitest + Testing Library für Welcome Landing, Dashboard-Guard und Checkout-Komponenten; `npm run test:unit` führt Suite aus.
- [x] Finalise direct checkout: Stripe/PayPal-Flows markieren Fortschritt, API-Mocks + Unit-Tests decken Erfolgs- und Fehlerpfade ab. - [x] Finalise direct checkout: Paddle-Flows markieren Fortschritt, API-Mocks + Unit-Tests decken Erfolgs- und Fehlerpfade ab.
- [x] Lokalisierung ausbauen: Landing-, Packages-, Summary- und Event-Setup-Screens sind nun DE/EN übersetzt; Copy-Review für weitere Module (Tasks/Billing/Members) bleibt offen. - [x] Lokalisierung ausbauen: Landing-, Packages-, Summary- und Event-Setup-Screens sind nun DE/EN übersetzt; Copy-Review für weitere Module (Tasks/Billing/Members) bleibt offen.
## Risks & Open Questions ## Risks & Open Questions
- Confirm checkout UX expectations (Stripe vs PayPal) before wiring package purchase into onboarding. - Confirm checkout UX expectations (Stripe vs Paddle) before wiring package purchase into onboarding.
- Validate whether onboarding flow must be localized at launch; coordinate mit den neuen i18n JSONs und fehlenden Übersetzungen. - Validate whether onboarding flow must be localized at launch; coordinate mit den neuen i18n JSONs und fehlenden Übersetzungen.
- Determine deprecation plan for fotospiel-tenant-app/tenant-admin-app once the merged flow ships. - Determine deprecation plan for fotospiel-tenant-app/tenant-admin-app once the merged flow ships.

File diff suppressed because one or more lines are too long

View File

@@ -10,14 +10,14 @@
"contact": "Kontakt", "contact": "Kontakt",
"vat_id": "Umsatzsteuer-ID: DE123456789", "vat_id": "Umsatzsteuer-ID: DE123456789",
"monetization": "Monetarisierung", "monetization": "Monetarisierung",
"monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de", "monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. Preise exkl. MwSt. Support: support@fotospiel.de",
"register_court": "Registergericht: Amtsgericht Musterstadt", "register_court": "Registergericht: Amtsgericht Musterstadt",
"commercial_register": "Handelsregister: HRB 12345", "commercial_register": "Handelsregister: HRB 12345",
"datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.", "datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.",
"responsible": "Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt", "responsible": "Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt",
"data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.", "data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.",
"payments": "Zahlungen und Packages", "payments": "Zahlungen und Packages",
"payments_desc": "Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert alle Daten werden verschlüsselt übertragen.", "payments_desc": "Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Paddle verarbeitet.",
"data_retention": "Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.", "data_retention": "Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.",
"rights": "Ihre Rechte: Auskunft, Löschung, Widerspruch.", "rights": "Ihre Rechte: Auskunft, Löschung, Widerspruch.",
"cookies": "Cookies: Nur funktionale Cookies für die PWA.", "cookies": "Cookies: Nur funktionale Cookies für die PWA.",
@@ -29,7 +29,6 @@
"data_security_desc": "Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).", "data_security_desc": "Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).",
"and": "und", "and": "und",
"stripe_privacy": "Stripe Datenschutz", "stripe_privacy": "Stripe Datenschutz",
"paypal_privacy": "PayPal Datenschutz",
"agb": "Allgemeine Geschäftsbedingungen", "agb": "Allgemeine Geschäftsbedingungen",
"effective_from": "Gültig seit {{date}}", "effective_from": "Gültig seit {{date}}",
"version": "Version {{version}}" "version": "Version {{version}}"

View File

@@ -88,7 +88,7 @@
"faq_q3": "Was passiert bei Ablauf?", "faq_q3": "Was passiert bei Ablauf?",
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.", "faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
"faq_q4": "Zahlungssicher?", "faq_q4": "Zahlungssicher?",
"faq_a4": "Ja, via Stripe oder PayPal sicher und GDPR-konform.", "faq_a4": "Ja, via Paddle sicher und GDPR-konform.",
"final_cta": "Bereit für Ihr nächstes Event?", "final_cta": "Bereit für Ihr nächstes Event?",
"contact_us": "Kontaktieren Sie uns", "contact_us": "Kontaktieren Sie uns",
"feature_live_slideshow": "Live-Slideshow", "feature_live_slideshow": "Live-Slideshow",
@@ -115,7 +115,7 @@
"billing_per_year": "pro Jahr", "billing_per_year": "pro Jahr",
"more_features": "+{{count}} weitere Features", "more_features": "+{{count}} weitere Features",
"feature_overview": "Feature-Überblick", "feature_overview": "Feature-Überblick",
"order_hint": "Sofort startklar keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.", "order_hint": "Sofort startklar keine versteckten Kosten, sichere Zahlung über Paddle.",
"features_label": "Features", "features_label": "Features",
"feature_highlights": "Feature-Highlights", "feature_highlights": "Feature-Highlights",
"more_details_tab": "Mehr Details", "more_details_tab": "Mehr Details",
@@ -145,7 +145,7 @@
"faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.", "faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.",
"faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.", "faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.",
"faq_reseller_desc": "Reseller-Packages sind jährliche Abos für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.", "faq_reseller_desc": "Reseller-Packages sind jährliche Abos für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.",
"faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Stripe oder PayPal abgewickelt. Ihre Daten sind GDPR-konform geschützt.", "faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Paddle abgewickelt. Ihre Daten sind GDPR-konform geschützt.",
"testimonials": { "testimonials": {
"anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.", "anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.",
"max": "Als Event-Organisator liebe ich die Analytics und das einfache Branding. Super für Firmenevents!", "max": "Als Event-Organisator liebe ich die Analytics und das einfache Branding. Super für Firmenevents!",
@@ -163,7 +163,9 @@
"euro": "€" "euro": "€"
}, },
"view_details": "Details ansehen", "view_details": "Details ansehen",
"feature": "Feature" "feature": "Feature",
"paddle_not_configured": "Dieses Package ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.",
"paddle_checkout_failed": "Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut."
}, },
"blog": { "blog": {
"title": "Fotospiel - Blog", "title": "Fotospiel - Blog",
@@ -280,22 +282,15 @@
"no_account": "Kein Konto? Registrieren", "no_account": "Kein Konto? Registrieren",
"manage_subscription": "Abo verwalten", "manage_subscription": "Abo verwalten",
"stripe_dashboard": "Stripe-Dashboard", "stripe_dashboard": "Stripe-Dashboard",
"paypal_dashboard": "PayPal-Dashboard",
"trial_activated": "Trial aktiviert für 14 Tage!" "trial_activated": "Trial aktiviert für 14 Tage!"
}, },
"payment": { "payment": {
"title": "Zahlung", "title": "Zahlung",
"card_details": "Kartendetails", "card_details": "Kartendetails",
"stripe": "Kreditkarte", "stripe": "Kreditkarte",
"paypal": "PayPal",
"submit_stripe": "Bezahlen mit Karte (:price)", "submit_stripe": "Bezahlen mit Karte (:price)",
"submit_paypal": "Bezahlen mit PayPal (:price)",
"loading_stripe": "Lade Stripe...", "loading_stripe": "Lade Stripe...",
"paypal_description": "Sichere Zahlung mit PayPal",
"switch_to_card": "Zur Kreditkarte wechseln", "switch_to_card": "Zur Kreditkarte wechseln",
"paypal_create_error": "PayPal-Bestellung fehlgeschlagen",
"paypal_capture_error": "PayPal-Capture fehlgeschlagen",
"paypal_error": "PayPal-Zahlung fehlgeschlagen",
"stripe_error": "Stripe-Zahlung fehlgeschlagen", "stripe_error": "Stripe-Zahlung fehlgeschlagen",
"confirm_error": "Bestätigung fehlgeschlagen", "confirm_error": "Bestätigung fehlgeschlagen",
"complete_error": "Zahlung konnte nicht abgeschlossen werden" "complete_error": "Zahlung konnte nicht abgeschlossen werden"
@@ -404,8 +399,19 @@
"free_package_desc": "Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.", "free_package_desc": "Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.",
"activate_package": "Paket aktivieren", "activate_package": "Paket aktivieren",
"loading_payment": "Zahlungsdaten werden geladen...", "loading_payment": "Zahlungsdaten werden geladen...",
"secure_payment_desc": "Sichere Zahlung mit Kreditkarte, Debitkarte oder SEPA-Lastschrift.", "secure_payment_desc": "Sichere Zahlung über Paddle.",
"secure_paypal_desc": "Sichere Zahlung mit PayPal.", "paddle_intro": "Wir öffnen den Paddle-Checkout direkt hier im Wizard, damit du im Ablauf bleibst.",
"paddle_preparing": "Paddle-Checkout wird vorbereitet…",
"paddle_overlay_ready": "Der Paddle-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.",
"paddle_ready": "Paddle-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.",
"paddle_error": "Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.",
"paddle_not_ready": "Der Paddle-Checkout ist noch nicht bereit. Bitte versuche es in einem Moment erneut.",
"paddle_not_configured": "Dieses Paket ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.",
"paddle_disclaimer": "Paddle wickelt Zahlungen als Merchant of Record ab. Steuern werden automatisch anhand deiner Rechnungsdaten berechnet.",
"pay_with_paddle": "Weiter mit Paddle",
"continue_after_payment": "Ich habe die Zahlung abgeschlossen",
"no_package_title": "Kein Paket ausgewählt",
"no_package_description": "Bitte wähle ein Paket, um zum Checkout zu gelangen.",
"payment_failed": "Zahlung fehlgeschlagen. ", "payment_failed": "Zahlung fehlgeschlagen. ",
"error_card": "Kartenfehler aufgetreten.", "error_card": "Kartenfehler aufgetreten.",
"error_validation": "Eingabedaten sind ungültig.", "error_validation": "Eingabedaten sind ungültig.",
@@ -419,25 +425,18 @@
"unexpected_status": "Unerwarteter Zahlungsstatus: {status}", "unexpected_status": "Unerwarteter Zahlungsstatus: {status}",
"processing_btn": "Verarbeitung...", "processing_btn": "Verarbeitung...",
"pay_now": "Jetzt bezahlen (€{price})", "pay_now": "Jetzt bezahlen (€{price})",
"stripe_not_loaded": "Stripe ist nicht initialisiert. Bitte Seite neu laden.",
"network_error": "Netzwerkfehler beim Laden der Zahlungsdaten", "network_error": "Netzwerkfehler beim Laden der Zahlungsdaten",
"payment_intent_error": "Fehler beim Laden der Zahlungsdaten",
"paypal_order_error": "PayPal-Bestellung konnte nicht erstellt werden. Bitte erneut versuchen.",
"paypal_capture_error": "PayPal-Abschluss fehlgeschlagen. Bitte erneut versuchen.",
"paypal_error": "PayPal meldete einen Fehler.",
"paypal_cancelled": "Sie haben die PayPal-Zahlung abgebrochen.",
"paypal_missing_plan": "Für dieses Paket fehlt die PayPal-Plan-Konfiguration. Bitte wählen Sie eine andere Zahlungsmethode.",
"auth_required": "Bitte melde dich an, um mit der Zahlung fortzufahren.", "auth_required": "Bitte melde dich an, um mit der Zahlung fortzufahren.",
"status_loading": "Zahlungsvorbereitung läuft…", "status_loading": "Zahlungsvorbereitung läuft…",
"status_ready": "Zahlungsformular bereit. Bitte gib deine Daten ein.", "status_ready": "Zahlungsformular bereit. Bitte gib deine Daten ein.",
"status_processing": "Zahlung mit {{provider}} wird verarbeitet…", "status_processing": "Zahlung mit {{provider}} wird verarbeitet…",
"status_success": "Zahlung bestätigt. Wir schließen den Kauf ab…", "status_success": "Zahlung bestätigt. Wir schließen den Kauf ab…",
"status_info_title": "Zahlungsstatus", "status_info_title": "Zahlungsstatus",
"status_processing_title": "Checkout wird geöffnet",
"status_ready_title": "Checkout geöffnet",
"status_error_title": "Zahlung fehlgeschlagen", "status_error_title": "Zahlung fehlgeschlagen",
"status_success_title": "Zahlung abgeschlossen", "status_success_title": "Zahlung abgeschlossen",
"status_retry": "Erneut versuchen", "status_retry": "Erneut versuchen"
"method_stripe": "Kreditkarte (Stripe)",
"method_paypal": "PayPal"
}, },
"confirmation_step": { "confirmation_step": {
"title": "Bestätigung", "title": "Bestätigung",

View File

@@ -10,14 +10,14 @@
"contact": "Contact", "contact": "Contact",
"vat_id": "VAT ID: DE123456789", "vat_id": "VAT ID: DE123456789",
"monetization": "Monetization", "monetization": "Monetization",
"monetization_desc": "We monetize through Packages (one-time purchases and subscriptions) via Stripe and PayPal. Prices excl. VAT. Support: support@fotospiel.de", "monetization_desc": "We monetize through Packages (one-time purchases and subscriptions) via Paddle. Prices excl. VAT. Support: support@fotospiel.de",
"register_court": "Register Court: District Court Musterstadt", "register_court": "Register Court: District Court Musterstadt",
"commercial_register": "Commercial Register: HRB 12345", "commercial_register": "Commercial Register: HRB 12345",
"datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.", "datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.",
"responsible": "Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt", "responsible": "Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt",
"data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.", "data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.",
"payments": "Payments and Packages", "payments": "Payments and Packages",
"payments_desc": "We process payments for Packages via Stripe and PayPal. Card information is not stored all data is transmitted encrypted. See Stripe Privacy and PayPal Privacy.", "payments_desc": "We process payments for Packages via Paddle. Payment data is handled securely and encrypted by Paddle as the merchant of record.",
"data_retention": "Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.", "data_retention": "Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.",
"rights": "Your rights: Information, deletion, objection. Contact us under Contact.", "rights": "Your rights: Information, deletion, objection. Contact us under Contact.",
"cookies": "Cookies: Only functional cookies for the PWA.", "cookies": "Cookies: Only functional cookies for the PWA.",

View File

@@ -78,7 +78,7 @@
"faq_q3": "What happens when it expires?", "faq_q3": "What happens when it expires?",
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.", "faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.",
"faq_q4": "Payment secure?", "faq_q4": "Payment secure?",
"faq_a4": "Yes, via Stripe or PayPal secure and GDPR compliant.", "faq_a4": "Yes, via Paddle secure and GDPR compliant.",
"final_cta": "Ready for your next event?", "final_cta": "Ready for your next event?",
"contact_us": "Contact Us", "contact_us": "Contact Us",
"feature_live_slideshow": "Live Slideshow", "feature_live_slideshow": "Live Slideshow",
@@ -105,7 +105,7 @@
"billing_per_year": "per year", "billing_per_year": "per year",
"more_features": "+{{count}} more features", "more_features": "+{{count}} more features",
"feature_overview": "Feature overview", "feature_overview": "Feature overview",
"order_hint": "Launch instantly secure Stripe or PayPal checkout, no hidden fees.", "order_hint": "Launch instantly secure Paddle checkout, no hidden fees.",
"features_label": "Features", "features_label": "Features",
"feature_highlights": "Feature Highlights", "feature_highlights": "Feature Highlights",
"more_details_tab": "More Details", "more_details_tab": "More Details",
@@ -149,7 +149,9 @@
}, },
"currency": { "currency": {
"euro": "€" "euro": "€"
} },
"paddle_not_configured": "This package is not ready for Paddle checkout. Please contact support.",
"paddle_checkout_failed": "We could not start the Paddle checkout. Please try again later."
}, },
"blog": { "blog": {
"title": "Fotospiel - Blog", "title": "Fotospiel - Blog",
@@ -266,22 +268,15 @@
"no_account": "No Account? Register", "no_account": "No Account? Register",
"manage_subscription": "Manage Subscription", "manage_subscription": "Manage Subscription",
"stripe_dashboard": "Stripe Dashboard", "stripe_dashboard": "Stripe Dashboard",
"paypal_dashboard": "PayPal Dashboard",
"trial_activated": "Trial activated for 14 days!" "trial_activated": "Trial activated for 14 days!"
}, },
"payment": { "payment": {
"title": "Payment", "title": "Payment",
"card_details": "Card Details", "card_details": "Card Details",
"stripe": "Credit Card", "stripe": "Credit Card",
"paypal": "PayPal",
"submit_stripe": "Pay with Card (:price)", "submit_stripe": "Pay with Card (:price)",
"submit_paypal": "Pay with PayPal (:price)",
"loading_stripe": "Loading Stripe...", "loading_stripe": "Loading Stripe...",
"paypal_description": "Secure payment with PayPal",
"switch_to_card": "Switch to Credit Card", "switch_to_card": "Switch to Credit Card",
"paypal_create_error": "PayPal order creation failed",
"paypal_capture_error": "PayPal capture failed",
"paypal_error": "PayPal payment failed",
"stripe_error": "Stripe payment failed", "stripe_error": "Stripe payment failed",
"confirm_error": "Confirmation failed", "confirm_error": "Confirmation failed",
"complete_error": "Payment could not be completed" "complete_error": "Payment could not be completed"
@@ -398,8 +393,19 @@
"free_package_desc": "This package is free. We activate it directly after confirmation.", "free_package_desc": "This package is free. We activate it directly after confirmation.",
"activate_package": "Activate Package", "activate_package": "Activate Package",
"loading_payment": "Payment data is loading...", "loading_payment": "Payment data is loading...",
"secure_payment_desc": "Secure payment with credit card, debit card or SEPA direct debit.", "secure_payment_desc": "Secure payment with Paddle.",
"secure_paypal_desc": "Pay securely with PayPal.", "paddle_intro": "We open Paddle's secure checkout directly inside this wizard so you never leave the flow.",
"paddle_preparing": "Preparing Paddle checkout…",
"paddle_overlay_ready": "Paddle checkout is running in a secure overlay. Complete the payment there and then continue here.",
"paddle_ready": "Paddle checkout opened in a new tab. Complete the payment and then continue here.",
"paddle_error": "We could not start the Paddle checkout. Please try again.",
"paddle_not_ready": "Paddle checkout is not ready yet. Please try again in a moment.",
"paddle_not_configured": "This package is not ready for Paddle checkout. Please contact support.",
"paddle_disclaimer": "Paddle processes payments as merchant of record. Taxes are calculated automatically based on your billing details.",
"pay_with_paddle": "Continue with Paddle",
"continue_after_payment": "I completed the payment",
"no_package_title": "No package selected",
"no_package_description": "Please choose a package to continue to checkout.",
"payment_failed": "Payment failed. ", "payment_failed": "Payment failed. ",
"error_card": "Card error occurred.", "error_card": "Card error occurred.",
"error_validation": "Input data is invalid.", "error_validation": "Input data is invalid.",
@@ -413,25 +419,18 @@
"unexpected_status": "Unexpected payment status: {status}", "unexpected_status": "Unexpected payment status: {status}",
"processing_btn": "Processing...", "processing_btn": "Processing...",
"pay_now": "Pay Now (${price})", "pay_now": "Pay Now (${price})",
"stripe_not_loaded": "Stripe is not initialized. Please reload the page.",
"network_error": "Network error loading payment data", "network_error": "Network error loading payment data",
"payment_intent_error": "Error loading payment data",
"paypal_order_error": "Could not create the PayPal order. Please try again.",
"paypal_capture_error": "PayPal capture failed. Please try again.",
"paypal_error": "PayPal reported an error.",
"paypal_cancelled": "You cancelled the PayPal payment.",
"paypal_missing_plan": "Missing PayPal plan configuration for this package. Please choose another payment method.",
"auth_required": "Please log in to continue to payment.", "auth_required": "Please log in to continue to payment.",
"status_loading": "Preparing secure payment data…", "status_loading": "Preparing secure payment data…",
"status_ready": "Payment form ready. Enter your details to continue.", "status_ready": "Payment form ready. Enter your details to continue.",
"status_processing": "Processing payment with {{provider}}…", "status_processing": "Processing payment with {{provider}}…",
"status_success": "Payment confirmed. Finalising your order…", "status_success": "Payment confirmed. Finalising your order…",
"status_info_title": "Payment status", "status_info_title": "Payment status",
"status_processing_title": "We are opening the checkout",
"status_ready_title": "Checkout opened",
"status_error_title": "Payment failed", "status_error_title": "Payment failed",
"status_success_title": "Payment completed", "status_success_title": "Payment completed",
"status_retry": "Retry", "status_retry": "Retry"
"method_stripe": "Credit Card (Stripe)",
"method_paypal": "PayPal"
}, },
"confirmation_step": { "confirmation_step": {
"title": "Confirmation", "title": "Confirmation",

View File

@@ -119,6 +119,20 @@ export type CreditBalance = {
free_event_granted_at?: string | null; free_event_granted_at?: string | null;
}; };
export type PaddleTransactionSummary = {
id: string | null;
status: string | null;
amount: number | null;
currency: string | null;
origin: string | null;
checkout_id: string | null;
created_at: string | null;
updated_at: string | null;
receipt_url?: string | null;
grand_total?: number | null;
tax?: number | null;
};
export type CreditLedgerEntry = { export type CreditLedgerEntry = {
id: number; id: number;
delta: number; delta: number;
@@ -444,6 +458,25 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
}; };
} }
function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary {
const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total);
const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total);
return {
id: typeof entry.id === 'string' ? entry.id : entry.id ? String(entry.id) : null,
status: entry.status ?? null,
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
currency: entry.currency ?? entry.currency_code ?? 'EUR',
origin: entry.origin ?? null,
checkout_id: entry.checkout_id ?? (entry.details?.checkout_id ?? null),
created_at: entry.created_at ?? null,
updated_at: entry.updated_at ?? null,
receipt_url: entry.receipt_url ?? entry.invoice_url ?? null,
grand_total: entry.grand_total !== undefined && entry.grand_total !== null ? Number(entry.grand_total) : null,
tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null,
};
}
function normalizeTask(task: JsonValue): TenantTask { function normalizeTask(task: JsonValue): TenantTask {
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {}); const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {}); const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
@@ -813,6 +846,35 @@ export async function getTenantPackagesOverview(): Promise<{
return { packages, activePackage }; return { packages, activePackage };
} }
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
data: PaddleTransactionSummary[];
nextCursor: string | null;
hasMore: boolean;
}> {
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
const response = await authorizedFetch(`/api/v1/tenant/billing/transactions${query}`);
if (response.status === 404) {
return { data: [], nextCursor: null, hasMore: false };
}
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to load Paddle transactions', response.status, payload);
throw new Error('Failed to load Paddle transactions');
}
const payload = await safeJson(response) ?? {};
const entries = Array.isArray(payload.data) ? payload.data : [];
const meta = payload.meta ?? {};
return {
data: entries.map(normalizePaddleTransaction),
nextCursor: typeof meta.next === 'string' ? meta.next : null,
hasMore: Boolean(meta.has_more),
};
}
export async function getCreditBalance(): Promise<CreditBalance> { export async function getCreditBalance(): Promise<CreditBalance> {
const response = await authorizedFetch('/api/v1/tenant/credits/balance'); const response = await authorizedFetch('/api/v1/tenant/credits/balance');
if (response.status === 404) { if (response.status === 404) {
@@ -868,17 +930,17 @@ export async function createTenantPackagePaymentIntent(packageId: number): Promi
export async function completeTenantPackagePurchase(params: { export async function completeTenantPackagePurchase(params: {
packageId: number; packageId: number;
paymentMethodId?: string; paymentMethodId?: string;
paypalOrderId?: string; paddleTransactionId?: string;
}): Promise<void> { }): Promise<void> {
const { packageId, paymentMethodId, paypalOrderId } = params; const { packageId, paymentMethodId, paddleTransactionId } = params;
const payload: Record<string, unknown> = { package_id: packageId }; const payload: Record<string, unknown> = { package_id: packageId };
if (paymentMethodId) { if (paymentMethodId) {
payload.payment_method_id = paymentMethodId; payload.payment_method_id = paymentMethodId;
} }
if (paypalOrderId) { if (paddleTransactionId) {
payload.paypal_order_id = paypalOrderId; payload.paddle_transaction_id = paddleTransactionId;
} }
const response = await authorizedFetch('/api/v1/tenant/packages/complete', { const response = await authorizedFetch('/api/v1/tenant/packages/complete', {
@@ -904,8 +966,8 @@ export async function assignFreeTenantPackage(packageId: number): Promise<void>
await jsonOrThrow(response, 'Failed to assign free package'); await jsonOrThrow(response, 'Failed to assign free package');
} }
export async function createTenantPayPalOrder(packageId: number): Promise<string> { export async function createTenantPaddleCheckout(packageId: number): Promise<{ checkout_url: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paypal-create', { const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -913,24 +975,12 @@ export async function createTenantPayPalOrder(packageId: number): Promise<string
body: JSON.stringify({ package_id: packageId }), body: JSON.stringify({ package_id: packageId }),
}); });
const data = await jsonOrThrow<{ orderID: string }>(response, 'Failed to create PayPal order'); const data = await jsonOrThrow<{ checkout_url: string }>(response, 'Failed to create Paddle checkout');
if (!data.orderID) { if (!data.checkout_url) {
throw new Error('Missing PayPal order ID'); throw new Error('Missing Paddle checkout URL');
} }
return data.orderID; return { checkout_url: data.checkout_url };
}
export async function captureTenantPayPalOrder(orderId: string): Promise<void> {
const response = await authorizedFetch('/api/v1/tenant/packages/paypal-capture', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ order_id: orderId }),
});
await jsonOrThrow(response, 'Failed to capture PayPal order');
} }
export async function recordCreditPurchase(payload: { export async function recordCreditPurchase(payload: {

View File

@@ -58,6 +58,7 @@ export interface StoredTokens {
refreshToken: string; refreshToken: string;
expiresAt: number; expiresAt: number;
scope?: string; scope?: string;
clientId?: string;
} }
export interface TokenResponse { export interface TokenResponse {
@@ -83,13 +84,14 @@ export function loadTokens(): StoredTokens | null {
return stored; return stored;
} }
export function saveTokens(response: TokenResponse): StoredTokens { export function saveTokens(response: TokenResponse, clientId: string = getClientId()): StoredTokens {
const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000; const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
const stored: StoredTokens = { const stored: StoredTokens = {
accessToken: response.access_token, accessToken: response.access_token,
refreshToken: response.refresh_token, refreshToken: response.refresh_token,
expiresAt, expiresAt,
scope: response.scope, scope: response.scope,
clientId,
}; };
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored)); localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
return stored; return stored;
@@ -110,19 +112,21 @@ export async function ensureAccessToken(): Promise<string> {
return tokens.accessToken; return tokens.accessToken;
} }
return refreshAccessToken(tokens.refreshToken); return refreshAccessToken(tokens);
} }
async function refreshAccessToken(refreshToken: string): Promise<string> { async function refreshAccessToken(tokens: StoredTokens): Promise<string> {
if (!refreshToken) { const clientId = tokens.clientId ?? getClientId();
if (!tokens.refreshToken) {
notifyAuthFailure(); notifyAuthFailure();
throw new AuthError('unauthenticated', 'Missing refresh token'); throw new AuthError('unauthenticated', 'Missing refresh token');
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: tokens.refreshToken,
client_id: getClientId(), client_id: clientId,
}); });
const response = await fetch(TOKEN_ENDPOINT, { const response = await fetch(TOKEN_ENDPOINT, {
@@ -138,7 +142,7 @@ async function refreshAccessToken(refreshToken: string): Promise<string> {
} }
const data = (await response.json()) as TokenResponse; const data = (await response.json()) as TokenResponse;
const stored = saveTokens(data); const stored = saveTokens(data, clientId);
return stored.accessToken; return stored.accessToken;
} }
@@ -215,10 +219,12 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
localStorage.removeItem(CODE_VERIFIER_KEY); localStorage.removeItem(CODE_VERIFIER_KEY);
localStorage.removeItem(STATE_KEY); localStorage.removeItem(STATE_KEY);
const clientId = getClientId();
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
client_id: getClientId(), client_id: clientId,
redirect_uri: buildRedirectUri(), redirect_uri: buildRedirectUri(),
code_verifier: verifier, code_verifier: verifier,
}); });
@@ -237,7 +243,7 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
} }
const data = (await response.json()) as TokenResponse; const data = (await response.json()) as TokenResponse;
saveTokens(data); saveTokens(data, clientId);
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY); const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
if (redirectTarget) { if (redirectTarget) {

View File

@@ -4,6 +4,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
refreshToken: string; refreshToken: string;
expiresAt: number; expiresAt: number;
scope?: string; scope?: string;
clientId?: string;
}; };
const CLIENTS: Record<string, string> = { const CLIENTS: Record<string, string> = {
@@ -29,11 +30,9 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens)); localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
window.location.assign('/event-admin/dashboard'); window.location.assign('/event-admin/dashboard');
} catch (error) { } catch (error) {
if (error instanceof Error) { const message = error instanceof Error ? error.message : String(error);
console.error('[DevAuth] Failed to login', error.message); console.error('[DevAuth] Failed to login', message);
} else { throw error instanceof Error ? error : new Error(message);
console.error('[DevAuth] Failed to login', error);
}
} }
} }
@@ -84,6 +83,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
refreshToken: body.refresh_token, refreshToken: body.refresh_token,
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000, expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
scope: body.scope, scope: body.scope,
clientId,
}; };
} }
@@ -126,10 +126,12 @@ function requestAuthorization(url: string): Promise<URL> {
} }
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location'); const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
if (xhr.status >= 200 && xhr.status < 400 && responseUrl) { if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) {
if (responseUrl) {
resolve(new URL(responseUrl, window.location.origin)); resolve(new URL(responseUrl, window.location.origin));
return; return;
} }
}
reject(new Error(`Authorize failed with ${xhr.status}`)); reject(new Error(`Authorize failed with ${xhr.status}`));
}; };

View File

@@ -45,6 +45,27 @@
"available": "Verfügbar", "available": "Verfügbar",
"expires": "Läuft ab" "expires": "Läuft ab"
} }
},
"transactions": {
"title": "Paddle-Transaktionen",
"description": "Neueste Paddle-Transaktionen für diesen Tenant.",
"empty": "Noch keine Paddle-Transaktionen.",
"labels": {
"transactionId": "Transaktion {{id}}",
"checkoutId": "Checkout-ID: {{id}}",
"origin": "Herkunft: {{origin}}",
"receipt": "Beleg ansehen",
"tax": "Steuer: {{value}}"
},
"status": {
"completed": "Abgeschlossen",
"processing": "Verarbeitung",
"failed": "Fehlgeschlagen",
"cancelled": "Storniert",
"unknown": "Unbekannt"
},
"loadMore": "Weitere Transaktionen laden",
"loadingMore": "Laden…"
} }
}, },
"packages": { "packages": {
@@ -149,8 +170,7 @@
"high": "Hoch", "high": "Hoch",
"urgent": "Dringend" "urgent": "Dringend"
} }
} },
,
"collections": { "collections": {
"title": "Aufgabenvorlagen", "title": "Aufgabenvorlagen",
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.", "subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",
@@ -244,8 +264,7 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"submit": "Emotion speichern" "submit": "Emotion speichern"
} }
} },
,
"management": { "management": {
"billing": { "billing": {
"title": "Pakete & Abrechnung", "title": "Pakete & Abrechnung",
@@ -298,4 +317,3 @@
} }
} }
} }

View File

@@ -165,46 +165,25 @@
"failureTitle": "Aktivierung fehlgeschlagen", "failureTitle": "Aktivierung fehlgeschlagen",
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
}, },
"stripe": { "paddle": {
"sectionTitle": "Kartenzahlung (Stripe)", "sectionTitle": "Paddle",
"heading": "Kartenzahlung", "heading": "Checkout mit Paddle",
"notReady": "Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.", "genericError": "Der Paddle-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.",
"genericError": "Zahlung fehlgeschlagen. Bitte erneut versuchen.", "errorTitle": "Paddle-Fehler",
"missingPaymentId": "Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).", "processing": "Paddle-Checkout wird geöffnet …",
"completionFailed": "Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.", "cta": "Paddle-Checkout öffnen",
"errorTitle": "Zahlung fehlgeschlagen", "hint": "Es öffnet sich ein neuer Tab über Paddle (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück."
"submitting": "Zahlung wird bestätigt …",
"submit": "Jetzt bezahlen",
"hint": "Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.",
"loading": "Zahlungsdetails werden geladen …",
"unavailableTitle": "Stripe nicht verfügbar",
"unavailableDescription": "Stripe konnte nicht initialisiert werden.",
"missingKey": "Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.",
"intentFailed": "Stripe-Zahlung konnte nicht vorbereitet werden."
},
"paypal": {
"sectionTitle": "PayPal",
"heading": "PayPal",
"createFailed": "PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.",
"captureFailed": "PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.",
"errorTitle": "PayPal-Fehler",
"genericError": "PayPal hat ein Problem gemeldet. Bitte versuche es später erneut.",
"missingOrderId": "PayPal hat keine Order-ID geliefert.",
"cancelled": "PayPal-Zahlung wurde abgebrochen.",
"hint": "PayPal leitet dich ggf. weiter, um die Zahlung zu bestätigen. Anschließend kommst du automatisch zurück.",
"notConfiguredTitle": "PayPal nicht konfiguriert",
"notConfiguredDescription": "Hinterlege VITE_PAYPAL_CLIENT_ID, damit Gastgeber optional mit PayPal bezahlen können."
}, },
"nextStepsTitle": "Nächste Schritte", "nextStepsTitle": "Nächste Schritte",
"nextSteps": [ "nextSteps": [
"Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.", "Optional: Abrechnung über Paddle im Billing-Bereich abschließen.",
"Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.", "Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.",
"Vor dem Go-Live Credits prüfen und Gäste-Link teilen." "Vor dem Go-Live Credits prüfen und Gäste-Link teilen."
], ],
"cta": { "cta": {
"billing": { "billing": {
"label": "Abrechnung starten", "label": "Abrechnung starten",
"description": "Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).", "description": "Öffnet den Billing-Bereich mit Paddle- und Credit-Optionen.",
"button": "Zu Billing & Zahlung" "button": "Zu Billing & Zahlung"
}, },
"setup": { "setup": {
@@ -262,7 +241,3 @@
} }
} }
} }

View File

@@ -45,6 +45,27 @@
"available": "Remaining", "available": "Remaining",
"expires": "Expires" "expires": "Expires"
} }
},
"transactions": {
"title": "Paddle transactions",
"description": "Recent Paddle transactions for this tenant.",
"empty": "No Paddle transactions yet.",
"labels": {
"transactionId": "Transaction {{id}}",
"checkoutId": "Checkout ID: {{id}}",
"origin": "Origin: {{origin}}",
"receipt": "View receipt",
"tax": "Tax: {{value}}"
},
"status": {
"completed": "Completed",
"processing": "Processing",
"failed": "Failed",
"cancelled": "Cancelled",
"unknown": "Unknown"
},
"loadMore": "Load more transactions",
"loadingMore": "Loading…"
} }
}, },
"packages": { "packages": {
@@ -149,8 +170,7 @@
"high": "High", "high": "High",
"urgent": "Urgent" "urgent": "Urgent"
} }
} },
,
"collections": { "collections": {
"title": "Task collections", "title": "Task collections",
"subtitle": "Browse curated task bundles or activate them for your events.", "subtitle": "Browse curated task bundles or activate them for your events.",
@@ -244,8 +264,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"submit": "Save emotion" "submit": "Save emotion"
} }
} },
,
"management": { "management": {
"billing": { "billing": {
"title": "Packages & billing", "title": "Packages & billing",

View File

@@ -165,46 +165,25 @@
"failureTitle": "Activation failed", "failureTitle": "Activation failed",
"errorMessage": "The free package could not be activated." "errorMessage": "The free package could not be activated."
}, },
"stripe": { "paddle": {
"sectionTitle": "Card payment (Stripe)", "sectionTitle": "Paddle",
"heading": "Card payment", "heading": "Checkout with Paddle",
"notReady": "Payment module not ready yet. Please refresh.", "genericError": "The Paddle checkout could not be opened. Please try again.",
"genericError": "Payment failed. Please try again.", "errorTitle": "Paddle error",
"missingPaymentId": "Could not confirm payment (missing payment ID).", "processing": "Opening the Paddle checkout …",
"completionFailed": "Purchase not recorded yet. Contact support with your payment confirmation.", "cta": "Open Paddle checkout",
"errorTitle": "Payment failed", "hint": "A new tab opens via Paddle (merchant of record). Complete the payment there, then return to continue."
"submitting": "Confirming payment …",
"submit": "Pay now",
"hint": "Secure checkout via Stripe. You'll receive confirmation once recorded.",
"loading": "Loading payment details …",
"unavailableTitle": "Stripe unavailable",
"unavailableDescription": "Stripe could not be initialised.",
"missingKey": "Stripe publishable key missing. Configure VITE_STRIPE_PUBLISHABLE_KEY.",
"intentFailed": "Stripe could not prepare the payment."
},
"paypal": {
"sectionTitle": "PayPal",
"heading": "PayPal",
"createFailed": "PayPal order could not be created. Please try again.",
"captureFailed": "PayPal payment could not be captured. Contact support if funds were withdrawn.",
"errorTitle": "PayPal error",
"genericError": "PayPal reported a problem. Please try again later.",
"missingOrderId": "PayPal did not return an order ID.",
"cancelled": "PayPal payment was cancelled.",
"hint": "PayPal may redirect you briefly to confirm. You'll return automatically afterwards.",
"notConfiguredTitle": "PayPal not configured",
"notConfiguredDescription": "Provide VITE_PAYPAL_CLIENT_ID so hosts can pay with PayPal."
}, },
"nextStepsTitle": "Next steps", "nextStepsTitle": "Next steps",
"nextSteps": [ "nextSteps": [
"Optional: finish billing (Stripe/PayPal) inside the billing area.", "Optional: finish billing via Paddle inside the billing area.",
"Complete the event setup and configure tasks, team, and gallery.", "Complete the event setup and configure tasks, team, and gallery.",
"Check credits before go-live and share your guest link." "Check credits before go-live and share your guest link."
], ],
"cta": { "cta": {
"billing": { "billing": {
"label": "Start billing", "label": "Start billing",
"description": "Opens the billing area with Stripe, PayPal, and credit options.", "description": "Opens the billing area with Paddle and credit options.",
"button": "Go to billing" "button": "Go to billing"
}, },
"setup": { "setup": {

View File

@@ -1,176 +1,65 @@
import React from 'react'; import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { act, render, screen, waitFor } from '@testing-library/react';
import { import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
StripeCheckoutForm,
PayPalCheckout,
} from '../pages/WelcomeOrderSummaryPage';
const stripeRef: { current: any } = { current: null }; const { createPaddleCheckoutMock } = vi.hoisted(() => ({
const elementsRef: { current: any } = { current: null }; createPaddleCheckoutMock: vi.fn(),
const paypalPropsRef: { current: any } = { current: null };
const {
confirmPaymentMock,
completePurchaseMock,
createPayPalOrderMock,
capturePayPalOrderMock,
} = vi.hoisted(() => ({
confirmPaymentMock: vi.fn(),
completePurchaseMock: vi.fn(),
createPayPalOrderMock: vi.fn(),
capturePayPalOrderMock: vi.fn(),
}));
vi.mock('@stripe/react-stripe-js', () => ({
useStripe: () => stripeRef.current,
useElements: () => elementsRef.current,
PaymentElement: () => <div data-testid="stripe-payment-element" />,
Elements: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@paypal/react-paypal-js', () => ({
PayPalScriptProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PayPalButtons: (props: any) => {
paypalPropsRef.current = props;
return <button type="button" data-testid="paypal-button">PayPal</button>;
},
})); }));
vi.mock('../../api', () => ({ vi.mock('../../api', () => ({
completeTenantPackagePurchase: completePurchaseMock,
createTenantPackagePaymentIntent: vi.fn(),
assignFreeTenantPackage: vi.fn(), assignFreeTenantPackage: vi.fn(),
createTenantPayPalOrder: createPayPalOrderMock, createTenantPaddleCheckout: createPaddleCheckoutMock,
captureTenantPayPalOrder: capturePayPalOrderMock,
})); }));
describe('StripeCheckoutForm', () => { describe('PaddleCheckout', () => {
beforeEach(() => { beforeEach(() => {
confirmPaymentMock.mockReset(); createPaddleCheckoutMock.mockReset();
completePurchaseMock.mockReset();
stripeRef.current = { confirmPayment: confirmPaymentMock };
elementsRef.current = {};
}); });
const renderStripeForm = (overrides?: Partial<React.ComponentProps<typeof StripeCheckoutForm>>) => it('opens Paddle checkout when created successfully', async () => {
render( createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
<StripeCheckoutForm
clientSecret="secret"
packageId={42}
onSuccess={vi.fn()}
t={(key: string) => key}
{...overrides}
/>
);
it('completes the purchase when Stripe reports a successful payment', async () => {
const onSuccess = vi.fn();
confirmPaymentMock.mockResolvedValue({
error: null,
paymentIntent: { payment_method: 'pm_123' },
});
completePurchaseMock.mockResolvedValue(undefined);
const { container } = renderStripeForm({ onSuccess });
const form = container.querySelector('form');
expect(form).toBeTruthy();
fireEvent.submit(form!);
await waitFor(() => {
expect(completePurchaseMock).toHaveBeenCalledWith({
packageId: 42,
paymentMethodId: 'pm_123',
});
});
expect(onSuccess).toHaveBeenCalled();
});
it('shows Stripe errors returned by confirmPayment', async () => {
confirmPaymentMock.mockResolvedValue({
error: { message: 'Card declined' },
});
const { container } = renderStripeForm();
fireEvent.submit(container.querySelector('form')!);
await waitFor(() => {
expect(screen.getByText('Card declined')).toBeInTheDocument();
});
expect(completePurchaseMock).not.toHaveBeenCalled();
});
it('reports missing payment method id', async () => {
confirmPaymentMock.mockResolvedValue({
error: null,
paymentIntent: {},
});
const { container } = renderStripeForm();
fireEvent.submit(container.querySelector('form')!);
await waitFor(() => {
expect(screen.getByText('summary.stripe.missingPaymentId')).toBeInTheDocument();
});
expect(completePurchaseMock).not.toHaveBeenCalled();
});
});
describe('PayPalCheckout', () => {
beforeEach(() => {
paypalPropsRef.current = null;
createPayPalOrderMock.mockReset();
capturePayPalOrderMock.mockReset();
});
it('creates and captures a PayPal order successfully', async () => {
createPayPalOrderMock.mockResolvedValue('ORDER-123');
capturePayPalOrderMock.mockResolvedValue(undefined);
const onSuccess = vi.fn(); const onSuccess = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
render( render(
<PayPalCheckout <PaddleCheckout
packageId={99} packageId={99}
onSuccess={onSuccess} onSuccess={onSuccess}
t={(key: string) => key} t={(key: string) => key}
/> />
); );
expect(paypalPropsRef.current).toBeTruthy();
const { createOrder, onApprove } = paypalPropsRef.current;
await act(async () => { await act(async () => {
const orderId = await createOrder(); screen.getByRole('button').click();
expect(orderId).toBe('ORDER-123');
});
await act(async () => {
await onApprove({ orderID: 'ORDER-123' });
}); });
await waitFor(() => { await waitFor(() => {
expect(createPayPalOrderMock).toHaveBeenCalledWith(99); expect(createPaddleCheckoutMock).toHaveBeenCalledWith(99);
expect(capturePayPalOrderMock).toHaveBeenCalledWith('ORDER-123'); expect(openSpy).toHaveBeenCalledWith('https://paddle.example/checkout', '_blank', 'noopener');
expect(onSuccess).toHaveBeenCalled(); expect(onSuccess).toHaveBeenCalled();
}); });
openSpy.mockRestore();
}); });
it('surfaces missing order id errors', async () => { it('shows an error message on failure', async () => {
createPayPalOrderMock.mockResolvedValue('ORDER-123'); createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
render( render(
<PayPalCheckout <PaddleCheckout
packageId={99} packageId={99}
onSuccess={vi.fn()} onSuccess={vi.fn()}
t={(key: string) => key} t={(key: string) => key}
/> />
); );
const { onApprove } = paypalPropsRef.current;
await act(async () => { await act(async () => {
await onApprove({ orderID: undefined }); screen.getByRole('button').click();
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('summary.paypal.missingOrderId')).toBeInTheDocument(); expect(screen.getByText('summary.paddle.genericError')).toBeInTheDocument();
}); });
expect(capturePayPalOrderMock).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -10,8 +10,6 @@ import {
AlertTriangle, AlertTriangle,
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
import { import {
TenantWelcomeLayout, TenantWelcomeLayout,
@@ -26,30 +24,15 @@ import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PA
import { useTenantPackages } from "../hooks/useTenantPackages"; import { useTenantPackages } from "../hooks/useTenantPackages";
import { import {
assignFreeTenantPackage, assignFreeTenantPackage,
completeTenantPackagePurchase, createTenantPaddleCheckout,
createTenantPackagePaymentIntent,
createTenantPayPalOrder,
captureTenantPayPalOrder,
} from "../../api"; } from "../../api";
import { getStripe } from '@/utils/stripe';
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; type PaddleCheckoutProps = {
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
type StripeCheckoutProps = {
clientSecret: string;
packageId: number; packageId: number;
onSuccess: () => void; onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"]; t: ReturnType<typeof useTranslation>["t"];
}; };
type PayPalCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
currency?: string;
};
function useLocaleFormats(locale: string) { function useLocaleFormats(locale: string) {
const currencyFormatter = React.useMemo( const currencyFormatter = React.useMemo(
() => () =>
@@ -86,175 +69,53 @@ function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string)
.join(" "); .join(" ");
} }
function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) { function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
const stripe = useStripe(); const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
const elements = useElements();
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => { const handleCheckout = React.useCallback(async () => {
event.preventDefault();
if (!stripe || !elements) {
setError(t("summary.stripe.notReady"));
return;
}
setSubmitting(true);
setError(null);
const result = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.href,
},
redirect: "if_required",
});
if (result.error) {
setError(result.error.message ?? t("summary.stripe.genericError"));
setSubmitting(false);
return;
}
const paymentIntent = result.paymentIntent;
const paymentMethodId =
typeof paymentIntent?.payment_method === "string"
? paymentIntent.payment_method
: typeof paymentIntent?.id === "string"
? paymentIntent.id
: null;
if (!paymentMethodId) {
setError(t("summary.stripe.missingPaymentId"));
setSubmitting(false);
return;
}
try { try {
await completeTenantPackagePurchase({ setStatus('processing');
packageId, setError(null);
paymentMethodId, const { checkout_url } = await createTenantPaddleCheckout(packageId);
}); window.open(checkout_url, '_blank', 'noopener');
setStatus('success');
onSuccess(); onSuccess();
} catch (purchaseError) { } catch (err) {
console.error("[Onboarding] Purchase completion failed", purchaseError); console.error('[Onboarding] Paddle checkout failed', err);
setError( setStatus('error');
purchaseError instanceof Error setError(err instanceof Error ? err.message : t('summary.paddle.genericError'));
? purchaseError.message
: t("summary.stripe.completionFailed")
);
setSubmitting(false);
} }
}; }, [packageId, onSuccess, t]);
return ( return (
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"> <div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
<div className="space-y-3"> <p className="text-sm font-medium text-brand-slate">{t('summary.paddle.heading')}</p>
<p className="text-sm font-medium text-brand-slate">{t("summary.stripe.heading")}</p>
<PaymentElement id="payment-element" />
</div>
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle> <AlertTitle>{t('summary.paddle.errorTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<Button <Button
type="submit" size="lg"
disabled={submitting || !stripe || !elements}
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60" className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
disabled={status === 'processing'}
onClick={handleCheckout}
> >
{submitting ? ( {status === 'processing' ? (
<> <>
<Loader2 className="mr-2 size-4 animate-spin" /> <Loader2 className="mr-2 size-4 animate-spin" />
{t("summary.stripe.submitting")} {t('summary.paddle.processing')}
</> </>
) : ( ) : (
<> <>
<CreditCard className="mr-2 size-4" /> <CreditCard className="mr-2 size-4" />
{t("summary.stripe.submit")} {t('summary.paddle.cta')}
</> </>
)} )}
</Button> </Button>
<p className="text-xs text-brand-navy/70">{t("summary.stripe.hint")}</p> <p className="text-xs text-brand-navy/70">{t('summary.paddle.hint')}</p>
</form>
);
}
function PayPalCheckout({ packageId, onSuccess, t, currency = "EUR" }: PayPalCheckoutProps) {
const [status, setStatus] = React.useState<"idle" | "creating" | "capturing" | "error" | "success">("idle");
const [error, setError] = React.useState<string | null>(null);
const handleCreateOrder = React.useCallback(async () => {
try {
setStatus("creating");
const orderId = await createTenantPayPalOrder(packageId);
setStatus("idle");
setError(null);
return orderId;
} catch (err) {
console.error("[Onboarding] PayPal create order failed", err);
setStatus("error");
setError(
err instanceof Error ? err.message : t("summary.paypal.createFailed")
);
throw err;
}
}, [packageId, t]);
const handleApprove = React.useCallback(
async (orderId: string) => {
try {
setStatus("capturing");
await captureTenantPayPalOrder(orderId);
setStatus("success");
setError(null);
onSuccess();
} catch (err) {
console.error("[Onboarding] PayPal capture failed", err);
setStatus("error");
setError(
err instanceof Error ? err.message : t("summary.paypal.captureFailed")
);
throw err;
}
},
[onSuccess, t]
);
return (
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
<p className="text-sm font-medium text-brand-slate">{t("summary.paypal.heading")}</p>
{error && (
<Alert variant="destructive">
<AlertTitle>{t("summary.paypal.errorTitle")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<PayPalButtons
style={{ layout: "vertical" }}
forceReRender={[packageId, currency]}
createOrder={async () => handleCreateOrder()}
onApprove={async (data) => {
if (!data.orderID) {
setError(t("summary.paypal.missingOrderId"));
setStatus("error");
return;
}
await handleApprove(data.orderID);
}}
onError={(err) => {
console.error("[Onboarding] PayPal onError", err);
setStatus("error");
setError(t("summary.paypal.genericError"));
}}
onCancel={() => {
setStatus("idle");
setError(t("summary.paypal.cancelled"));
}}
disabled={status === "creating" || status === "capturing"}
/>
<p className="text-xs text-brand-navy/70">{t("summary.paypal.hint")}</p>
</div> </div>
); );
} }
@@ -267,7 +128,6 @@ export default function WelcomeOrderSummaryPage() {
const { t, i18n } = useTranslation("onboarding"); const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE"; const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale); const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
const stripePromise = React.useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined; const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null; const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
@@ -295,48 +155,9 @@ export default function WelcomeOrderSummaryPage() {
const isSubscription = Boolean(packageDetails?.features?.subscription); const isSubscription = Boolean(packageDetails?.features?.subscription);
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0); const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
const [intentStatus, setIntentStatus] = React.useState<"idle" | "loading" | "error" | "ready">("idle");
const [intentError, setIntentError] = React.useState<string | null>(null);
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle"); const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null); const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!requiresPayment || !packageDetails) {
setClientSecret(null);
setIntentStatus("idle");
setIntentError(null);
return;
}
if (!stripePromise) {
setIntentError(t("summary.stripe.missingKey"));
setIntentStatus("error");
return;
}
let cancelled = false;
setIntentStatus("loading");
setIntentError(null);
createTenantPackagePaymentIntent(packageDetails.id)
.then((secret) => {
if (cancelled) return;
setClientSecret(secret);
setIntentStatus("ready");
})
.catch((error) => {
console.error("[Onboarding] Payment intent failed", error);
if (cancelled) return;
setIntentStatus("error");
setIntentError(error instanceof Error ? error.message : t("summary.stripe.intentFailed"));
});
return () => {
cancelled = true;
};
}, [requiresPayment, packageDetails, stripePromise, t]);
const priceText = const priceText =
progress.selectedPackage?.priceText ?? progress.selectedPackage?.priceText ??
(packageDetails && typeof packageDetails.price === "number" (packageDetails && typeof packageDetails.price === "number"
@@ -534,25 +355,9 @@ export default function WelcomeOrderSummaryPage() {
)} )}
{requiresPayment && ( {requiresPayment && (
<div className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.stripe.sectionTitle")}</h4> <h4 className="text-lg font-semibold text-brand-slate">{t('summary.paddle.sectionTitle')}</h4>
{intentStatus === "loading" && ( <PaddleCheckout
<div className="flex items-center gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 text-sm text-brand-navy/80">
<Loader2 className="size-4 animate-spin text-brand-rose" />
{t("summary.stripe.loading")}
</div>
)}
{intentStatus === "error" && (
<Alert variant="destructive">
<AlertTitle>{t("summary.stripe.unavailableTitle")}</AlertTitle>
<AlertDescription>{intentError ?? t("summary.stripe.unavailableDescription")}</AlertDescription>
</Alert>
)}
{intentStatus === "ready" && clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<StripeCheckoutForm
clientSecret={clientSecret}
packageId={packageDetails.id} packageId={packageDetails.id}
onSuccess={() => { onSuccess={() => {
markStep({ packageSelected: true }); markStep({ packageSelected: true });
@@ -560,37 +365,6 @@ export default function WelcomeOrderSummaryPage() {
}} }}
t={t} t={t}
/> />
</Elements>
)}
</div>
{paypalClientId ? (
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.paypal.sectionTitle")}</h4>
<PayPalScriptProvider
options={{
clientId: paypalClientId,
"client-id": paypalClientId,
currency: "EUR",
intent: "CAPTURE",
}}
>
<PayPalCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
</PayPalScriptProvider>
</div>
) : (
<Alert variant="default" className="border-amber-200 bg-amber-50 text-amber-700">
<AlertTitle>{t("summary.paypal.notConfiguredTitle")}</AlertTitle>
<AlertDescription>{t("summary.paypal.notConfiguredDescription")}</AlertDescription>
</Alert>
)}
</div> </div>
)} )}
@@ -634,4 +408,4 @@ export default function WelcomeOrderSummaryPage() {
); );
} }
export { StripeCheckoutForm, PayPalCheckout }; export { PaddleCheckout };

View File

@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { getTenantPackagesOverview, TenantPackageSummary } from '../api'; import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
export default function BillingPage() { export default function BillingPage() {
@@ -21,6 +21,10 @@ export default function BillingPage() {
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]); const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null); const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null);
const [transactionsHasMore, setTransactionsHasMore] = React.useState(false);
const [transactionsLoading, setTransactionsLoading] = React.useState(false);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -57,9 +61,18 @@ export default function BillingPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const packagesResult = await getTenantPackagesOverview(); const [packagesResult, paddleTransactions] = await Promise.all([
getTenantPackagesOverview(),
getTenantPaddleTransactions().catch((err) => {
console.warn('Failed to load Paddle transactions', err);
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
}),
]);
setPackages(packagesResult.packages); setPackages(packagesResult.packages);
setActivePackage(packagesResult.activePackage); setActivePackage(packagesResult.activePackage);
setTransactions(paddleTransactions.data);
setTransactionCursor(paddleTransactions.nextCursor);
setTransactionsHasMore(paddleTransactions.hasMore);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(t('billing.errors.load')); setError(t('billing.errors.load'));
@@ -69,6 +82,25 @@ export default function BillingPage() {
} }
}, [t]); }, [t]);
const loadMoreTransactions = React.useCallback(async () => {
if (!transactionsHasMore || transactionsLoading || !transactionCursor) {
return;
}
setTransactionsLoading(true);
try {
const result = await getTenantPaddleTransactions(transactionCursor);
setTransactions((current) => [...current, ...result.data]);
setTransactionCursor(result.nextCursor);
setTransactionsHasMore(result.hasMore && Boolean(result.nextCursor));
} catch (error) {
console.warn('Failed to load additional Paddle transactions', error);
setTransactionsHasMore(false);
} finally {
setTransactionsLoading(false);
}
}, [transactionCursor, transactionsHasMore, transactionsLoading]);
React.useEffect(() => { React.useEffect(() => {
void loadAll(); void loadAll();
}, [loadAll]); }, [loadAll]);
@@ -176,11 +208,134 @@ export default function BillingPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-sky-500" />
{t('billing.sections.transactions.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('billing.sections.transactions.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{transactions.length === 0 ? (
<EmptyState message={t('billing.sections.transactions.empty')} />
) : (
<div className="grid gap-3">
{transactions.map((transaction) => (
<TransactionCard
key={transaction.id ?? Math.random().toString(36).slice(2)}
transaction={transaction}
formatCurrency={formatCurrency}
formatDate={formatDate}
locale={locale}
t={t}
/>
))}
</div>
)}
{transactionsHasMore && (
<Button
variant="outline"
onClick={() => void loadMoreTransactions()}
disabled={transactionsLoading}
>
{transactionsLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('billing.sections.transactions.loadingMore')}
</>
) : (
t('billing.sections.transactions.loadMore')
)}
</Button>
)}
</CardContent>
</Card>
</> </>
)} )}
</AdminLayout> </AdminLayout>
); );
} }
function TransactionCard({
transaction,
formatCurrency,
formatDate,
locale,
t,
}: {
transaction: PaddleTransactionSummary;
formatCurrency: (value: number | null | undefined, currency?: string) => string;
formatDate: (value: string | null | undefined) => string;
locale: string;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const amount = transaction.grand_total ?? transaction.amount ?? null;
const currency = transaction.currency ?? 'EUR';
const createdAtIso = transaction.created_at ?? null;
const createdAt = createdAtIso ? new Date(createdAtIso) : null;
const createdLabel = createdAt
? createdAt.toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: formatDate(createdAtIso);
const statusKey = transaction.status ? `billing.sections.transactions.status.${transaction.status}` : 'billing.sections.transactions.status.unknown';
const statusText = t(statusKey, {
defaultValue: (transaction.status ?? 'unknown').replace(/_/g, ' '),
});
return (
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-800">
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
</p>
<p className="text-xs uppercase tracking-wide text-slate-500">{createdLabel}</p>
{transaction.checkout_id && (
<p className="text-xs text-slate-500">
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
</p>
)}
{transaction.origin && (
<p className="text-xs text-slate-500">
{t('billing.sections.transactions.labels.origin', { origin: transaction.origin })}
</p>
)}
</div>
<div className="flex flex-col items-start gap-2 text-sm font-medium text-slate-700 md:flex-row md:items-center md:gap-4">
<Badge className="bg-sky-100 text-sky-700">
{statusText}
</Badge>
<div className="text-base font-semibold text-slate-900">
{formatCurrency(amount, currency)}
</div>
{transaction.tax !== undefined && transaction.tax !== null && (
<span className="text-xs text-slate-500">
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })}
</span>
)}
{transaction.receipt_url && (
<a
href={transaction.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-sky-600 hover:text-sky-700"
>
{t('billing.sections.transactions.labels.receipt')}
</a>
)}
</div>
</div>
);
}
function InfoCard({ function InfoCard({
label, label,
value, value,

View File

@@ -15,10 +15,10 @@ const Footer: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div> <div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<img src="logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" /> <img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
<div> <div>
<Link href="/" className="text-2xl font-bold font-display text-pink-500"> <Link href="/" className="text-2xl font-bold font-display text-pink-500">
FotoSpiel.App Die FotoSpiel.App
</Link> </Link>
<p className="text-gray-600 font-sans-marketing mt-2"> <p className="text-gray-600 font-sans-marketing mt-2">
Deine Plattform für Event-Fotos. Deine Plattform für Event-Fotos.
@@ -57,7 +57,7 @@ const Footer: React.FC = () => {
</div> </div>
<div className="border-t border-gray-200 mt-8 pt-8 text-center text-sm text-gray-500 font-sans-marketing"> <div className="border-t border-gray-200 mt-8 pt-8 text-center text-sm text-gray-500 font-sans-marketing">
&copy; 2025 FotoSpiel.App - Alle Rechte vorbehalten. &copy; 2025 Die FotoSpiel.App - Alle Rechte vorbehalten.
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -121,7 +121,7 @@ const Header: React.FC = () => {
<Link href={localizedPath('/')} className="flex items-center gap-4"> <Link href={localizedPath('/')} className="flex items-center gap-4">
<img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" /> <img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
<span className="text-2xl font-bold font-display text-pink-500"> <span className="text-2xl font-bold font-display text-pink-500">
FotoSpiel.App Die FotoSpiel.App
</span> </span>
</Link> </Link>
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}> <NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react'; import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import type { GoogleProfilePrefill } from '../marketing/checkout/types';
declare const route: (name: string, params?: Record<string, unknown>) => string; declare const route: (name: string, params?: Record<string, unknown>) => string;
@@ -18,6 +19,8 @@ interface RegisterFormProps {
onSuccess?: (payload: RegisterSuccessPayload) => void; onSuccess?: (payload: RegisterSuccessPayload) => void;
privacyHtml: string; privacyHtml: string;
locale?: string; locale?: string;
prefill?: GoogleProfilePrefill;
onClearGoogleProfile?: () => void;
} }
type RegisterFormFields = { type RegisterFormFields = {
@@ -30,13 +33,15 @@ type RegisterFormFields = {
address: string; address: string;
phone: string; phone: string;
privacy_consent: boolean; privacy_consent: boolean;
terms: boolean;
package_id: number | null; package_id: number | null;
}; };
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }: RegisterFormProps) { export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) {
const [privacyOpen, setPrivacyOpen] = useState(false); const [privacyOpen, setPrivacyOpen] = useState(false);
const [hasTriedSubmit, setHasTriedSubmit] = useState(false); const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [prefillApplied, setPrefillApplied] = useState(false);
const { t } = useTranslation(['auth', 'common']); const { t } = useTranslation(['auth', 'common']);
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>(); const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
const resolvedLocale = locale ?? page.props.locale ?? 'de'; const resolvedLocale = locale ?? page.props.locale ?? 'de';
@@ -51,6 +56,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
address: '', address: '',
phone: '', phone: '',
privacy_consent: false, privacy_consent: false,
terms: false,
package_id: packageId || null, package_id: packageId || null,
}); });
@@ -62,6 +68,62 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
const registerEndpoint = '/checkout/register'; const registerEndpoint = '/checkout/register';
const namePrefill = useMemo(() => {
const rawFirst = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? '';
const remaining = prefill?.name ? prefill.name.split(' ').slice(1).join(' ') : '';
const rawLast = prefill?.family_name ?? remaining;
return {
first: rawFirst ?? '',
last: rawLast ?? '',
};
}, [prefill]);
const suggestedUsername = useMemo(() => {
if (prefill?.email) {
const localPart = prefill.email.split('@')[0];
if (localPart) {
return localPart.slice(0, 30);
}
}
const first = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? '';
const last = prefill?.family_name ?? prefill?.name?.split(' ').slice(1).join(' ') ?? '';
const combined = `${first}${last}`.trim();
if (!combined) {
return undefined;
}
return combined
.toLowerCase()
.replace(/[^a-z0-9]+/g, '')
.slice(0, 30) || undefined;
}, [prefill]);
useEffect(() => {
if (!prefill || prefillApplied) {
return;
}
if (namePrefill.first && !data.first_name) {
setData('first_name', namePrefill.first);
}
if (namePrefill.last && !data.last_name) {
setData('last_name', namePrefill.last);
}
if (prefill.email && !data.email) {
setData('email', prefill.email);
}
if (suggestedUsername && !data.username) {
setData('username', suggestedUsername);
}
setPrefillApplied(true);
}, [prefill, namePrefill.first, namePrefill.last, data.first_name, data.last_name, data.email, data.username, prefillApplied, setData, suggestedUsername]);
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setHasTriedSubmit(true); setHasTriedSubmit(true);
@@ -95,6 +157,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
redirect: json?.redirect ?? null, redirect: json?.redirect ?? null,
pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false, pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false,
}); });
onClearGoogleProfile?.();
reset(); reset();
setHasTriedSubmit(false); setHasTriedSubmit(false);
return; return;
@@ -362,9 +425,13 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
checked={data.privacy_consent} checked={data.privacy_consent}
onChange={(e) => { onChange={(e) => {
setData('privacy_consent', e.target.checked); setData('privacy_consent', e.target.checked);
setData('terms', e.target.checked);
if (e.target.checked && errors.privacy_consent) { if (e.target.checked && errors.privacy_consent) {
clearErrors('privacy_consent'); clearErrors('privacy_consent');
} }
if (e.target.checked && errors.terms) {
clearErrors('terms');
}
}} }}
className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded" className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded"
/> />
@@ -379,6 +446,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
</button>. </button>.
</label> </label>
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>} {errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
{errors.terms && <p className="mt-2 text-sm text-red-600">{errors.terms}</p>}
</div> </div>
</div> </div>
@@ -419,8 +487,3 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
} }

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Head, usePage } from "@inertiajs/react"; import { Head, usePage } from "@inertiajs/react";
import MarketingLayout from "@/layouts/mainWebsite"; import MarketingLayout from "@/layouts/mainWebsite";
import type { CheckoutPackage } from "./checkout/types"; import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
import { CheckoutWizard } from "./checkout/CheckoutWizard"; import { CheckoutWizard } from "./checkout/CheckoutWizard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X } from "lucide-react"; import { X } from "lucide-react";
@@ -9,20 +9,28 @@ import { X } from "lucide-react";
interface CheckoutWizardPageProps { interface CheckoutWizardPageProps {
package: CheckoutPackage; package: CheckoutPackage;
packageOptions: CheckoutPackage[]; packageOptions: CheckoutPackage[];
stripePublishableKey: string;
paypalClientId: string;
privacyHtml: string; privacyHtml: string;
googleAuth?: {
status?: string | null;
error?: string | null;
profile?: GoogleProfilePrefill | null;
};
paddle?: {
environment?: string | null;
client_token?: string | null;
};
} }
export default function CheckoutWizardPage({ export default function CheckoutWizardPage({
package: initialPackage, package: initialPackage,
packageOptions, packageOptions,
stripePublishableKey,
paypalClientId,
privacyHtml, privacyHtml,
googleAuth,
paddle,
}: CheckoutWizardPageProps) { }: CheckoutWizardPageProps) {
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>(); const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>();
const currentUser = page.props.auth?.user ?? null; const currentUser = page.props.auth?.user ?? null;
const googleProfile = googleAuth?.profile ?? null;
const dedupedOptions = React.useMemo(() => { const dedupedOptions = React.useMemo(() => {
@@ -58,10 +66,10 @@ export default function CheckoutWizardPage({
<CheckoutWizard <CheckoutWizard
initialPackage={initialPackage} initialPackage={initialPackage}
packageOptions={dedupedOptions} packageOptions={dedupedOptions}
stripePublishableKey={stripePublishableKey}
paypalClientId={paypalClientId}
privacyHtml={privacyHtml} privacyHtml={privacyHtml}
initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null} initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null}
googleProfile={googleProfile}
paddle={paddle ?? null}
/> />
</div> </div>
</div> </div>

View File

@@ -1,10 +1,10 @@
import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy } from "react"; import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy, useState } from "react";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Steps } from "@/components/ui/Steps"; import { Steps } from "@/components/ui/Steps";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext"; import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
import type { CheckoutPackage, CheckoutStepId } from "./types"; import type { CheckoutPackage, CheckoutStepId, GoogleProfilePrefill } from "./types";
import { PackageStep } from "./steps/PackageStep"; import { PackageStep } from "./steps/PackageStep";
import { AuthStep } from "./steps/AuthStep"; import { AuthStep } from "./steps/AuthStep";
import { ConfirmationStep } from "./steps/ConfirmationStep"; import { ConfirmationStep } from "./steps/ConfirmationStep";
@@ -15,8 +15,6 @@ const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({
interface CheckoutWizardProps { interface CheckoutWizardProps {
initialPackage: CheckoutPackage; initialPackage: CheckoutPackage;
packageOptions: CheckoutPackage[]; packageOptions: CheckoutPackage[];
stripePublishableKey: string;
paypalClientId: string;
privacyHtml: string; privacyHtml: string;
initialAuthUser?: { initialAuthUser?: {
id: number; id: number;
@@ -25,6 +23,11 @@ interface CheckoutWizardProps {
pending_purchase?: boolean; pending_purchase?: boolean;
} | null; } | null;
initialStep?: CheckoutStepId; initialStep?: CheckoutStepId;
googleProfile?: GoogleProfilePrefill | null;
paddle?: {
environment?: string | null;
client_token?: string | null;
} | null;
} }
const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: string; detailsKey: string }[] = [ const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: string; detailsKey: string }[] = [
@@ -61,13 +64,34 @@ const PaymentStepFallback: React.FC = () => (
</div> </div>
); );
const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => { const WizardBody: React.FC<{
privacyHtml: string;
googleProfile?: GoogleProfilePrefill | null;
onClearGoogleProfile?: () => void;
}> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { currentStep, nextStep, previousStep } = useCheckoutWizard(); const {
currentStep,
nextStep,
previousStep,
selectedPackage,
authUser,
isAuthenticated,
paymentCompleted,
} = useCheckoutWizard();
const progressRef = useRef<HTMLDivElement | null>(null); const progressRef = useRef<HTMLDivElement | null>(null);
const hasMountedRef = useRef(false); const hasMountedRef = useRef(false);
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const isFreeSelected = useMemo(() => {
if (!selectedPackage) {
return false;
}
const priceValue = Number(selectedPackage.price);
return Number.isFinite(priceValue) && priceValue <= 0;
}, [selectedPackage]);
const stepConfig = useMemo(() => const stepConfig = useMemo(() =>
baseStepConfig.map(step => ({ baseStepConfig.map(step => ({
id: step.id, id: step.id,
@@ -114,7 +138,41 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
}); });
}, [currentStep]); }, [currentStep]);
const atLastStep = currentIndex >= stepConfig.length - 1;
const canProceedToNextStep = useMemo(() => {
if (atLastStep) {
return false;
}
if (currentStep === 'package') {
return Boolean(selectedPackage);
}
if (currentStep === 'auth') {
return Boolean(isAuthenticated && authUser);
}
if (currentStep === 'payment') {
return isFreeSelected || paymentCompleted;
}
return true;
}, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]);
const shouldShowNextButton = useMemo(() => {
if (currentStep !== 'payment') {
return true;
}
return isFreeSelected || paymentCompleted;
}, [currentStep, isFreeSelected, paymentCompleted]);
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
if (!canProceedToNextStep) {
return;
}
const targetStep = stepConfig[currentIndex + 1]?.id ?? 'end'; const targetStep = stepConfig[currentIndex + 1]?.id ?? 'end';
trackEvent({ trackEvent({
category: 'marketing_checkout', category: 'marketing_checkout',
@@ -122,7 +180,7 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
name: `${currentStep}->${targetStep}`, name: `${currentStep}->${targetStep}`,
}); });
nextStep(); nextStep();
}, [currentIndex, currentStep, nextStep, stepConfig, trackEvent]); }, [canProceedToNextStep, currentIndex, currentStep, nextStep, stepConfig, trackEvent]);
const handlePrevious = useCallback(() => { const handlePrevious = useCallback(() => {
const targetStep = stepConfig[currentIndex - 1]?.id ?? 'start'; const targetStep = stepConfig[currentIndex - 1]?.id ?? 'start';
@@ -151,10 +209,16 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
<div className="space-y-6"> <div className="space-y-6">
{currentStep === "package" && <PackageStep />} {currentStep === "package" && <PackageStep />}
{currentStep === "auth" && <AuthStep privacyHtml={privacyHtml} />} {currentStep === "auth" && (
<AuthStep
privacyHtml={privacyHtml}
googleProfile={googleProfile ?? undefined}
onClearGoogleProfile={onClearGoogleProfile}
/>
)}
{currentStep === "payment" && ( {currentStep === "payment" && (
<Suspense fallback={<PaymentStepFallback />}> <Suspense fallback={<PaymentStepFallback />}>
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} /> <PaymentStep />
</Suspense> </Suspense>
)} )}
{currentStep === "confirmation" && ( {currentStep === "confirmation" && (
@@ -162,13 +226,17 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
)} )}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<Button variant="ghost" onClick={handlePrevious} disabled={currentIndex <= 0}> <Button variant="ghost" onClick={handlePrevious} disabled={currentIndex <= 0}>
{t('checkout.back')} {t('checkout.back')}
</Button> </Button>
<Button onClick={handleNext} disabled={currentIndex >= stepConfig.length - 1}> {shouldShowNextButton ? (
<Button onClick={handleNext} disabled={!canProceedToNextStep}>
{t('checkout.next')} {t('checkout.next')}
</Button> </Button>
) : (
<div className="h-10 min-w-[128px]" aria-hidden="true" />
)}
</div> </div>
</div> </div>
); );
@@ -177,12 +245,51 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialPackage, initialPackage,
packageOptions, packageOptions,
stripePublishableKey,
paypalClientId,
privacyHtml, privacyHtml,
initialAuthUser, initialAuthUser,
initialStep, initialStep,
googleProfile,
paddle,
}) => { }) => {
const [storedProfile, setStoredProfile] = useState<GoogleProfilePrefill | null>(() => {
if (typeof window === 'undefined') {
return null;
}
const raw = window.localStorage.getItem('checkout-google-profile');
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as GoogleProfilePrefill;
} catch (error) {
console.warn('Failed to parse checkout google profile from storage', error);
window.localStorage.removeItem('checkout-google-profile');
return null;
}
});
useEffect(() => {
if (!googleProfile) {
return;
}
setStoredProfile(googleProfile);
if (typeof window !== 'undefined') {
window.localStorage.setItem('checkout-google-profile', JSON.stringify(googleProfile));
}
}, [googleProfile]);
const clearStoredProfile = useCallback(() => {
setStoredProfile(null);
if (typeof window !== 'undefined') {
window.localStorage.removeItem('checkout-google-profile');
}
}, []);
const effectiveProfile = googleProfile ?? storedProfile;
return ( return (
<CheckoutWizardProvider <CheckoutWizardProvider
@@ -191,8 +298,13 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialStep={initialStep} initialStep={initialStep}
initialAuthUser={initialAuthUser ?? undefined} initialAuthUser={initialAuthUser ?? undefined}
initialIsAuthenticated={Boolean(initialAuthUser)} initialIsAuthenticated={Boolean(initialAuthUser)}
paddle={paddle ?? null}
> >
<WizardBody stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} privacyHtml={privacyHtml} /> <WizardBody
privacyHtml={privacyHtml}
googleProfile={effectiveProfile}
onClearGoogleProfile={clearStoredProfile}
/>
</CheckoutWizardProvider> </CheckoutWizardProvider>
); );
}; };

View File

@@ -10,6 +10,7 @@ interface CheckoutState {
paymentIntent: string | null; paymentIntent: string | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
paymentCompleted: boolean;
} }
interface CheckoutWizardContextType { interface CheckoutWizardContextType {
@@ -19,6 +20,11 @@ interface CheckoutWizardContextType {
currentStep: CheckoutStepId; currentStep: CheckoutStepId;
isAuthenticated: boolean; isAuthenticated: boolean;
authUser: any; authUser: any;
paddleConfig?: {
environment?: string | null;
client_token?: string | null;
} | null;
paymentCompleted: boolean;
selectPackage: (pkg: CheckoutPackage) => void; selectPackage: (pkg: CheckoutPackage) => void;
setSelectedPackage: (pkg: CheckoutPackage) => void; setSelectedPackage: (pkg: CheckoutPackage) => void;
setAuthUser: (user: any) => void; setAuthUser: (user: any) => void;
@@ -31,6 +37,7 @@ interface CheckoutWizardContextType {
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
resetPaymentState: () => void; resetPaymentState: () => void;
setPaymentCompleted: (completed: boolean) => void;
} }
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null); const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
@@ -44,6 +51,7 @@ const initialState: CheckoutState = {
paymentIntent: null, paymentIntent: null,
loading: false, loading: false,
error: null, error: null,
paymentCompleted: false,
}; };
type CheckoutAction = type CheckoutAction =
@@ -54,14 +62,15 @@ type CheckoutAction =
| { type: 'GO_TO_STEP'; payload: CheckoutStepId } | { type: 'GO_TO_STEP'; payload: CheckoutStepId }
| { type: 'UPDATE_PAYMENT_INTENT'; payload: string | null } | { type: 'UPDATE_PAYMENT_INTENT'; payload: string | null }
| { type: 'SET_LOADING'; payload: boolean } | { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }; | { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_PAYMENT_COMPLETED'; payload: boolean };
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState { function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
switch (action.type) { switch (action.type) {
case 'SELECT_PACKAGE': case 'SELECT_PACKAGE':
return { ...state, selectedPackage: action.payload }; return { ...state, selectedPackage: action.payload, paymentCompleted: false };
case 'SET_AUTH_USER': case 'SET_AUTH_USER':
return { ...state, authUser: action.payload }; return { ...state, authUser: action.payload, isAuthenticated: Boolean(action.payload) };
case 'NEXT_STEP': case 'NEXT_STEP':
const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation']; const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
const currentIndex = steps.indexOf(state.currentStep); const currentIndex = steps.indexOf(state.currentStep);
@@ -84,6 +93,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout
return { ...state, loading: action.payload }; return { ...state, loading: action.payload };
case 'SET_ERROR': case 'SET_ERROR':
return { ...state, error: action.payload }; return { ...state, error: action.payload };
case 'SET_PAYMENT_COMPLETED':
return { ...state, paymentCompleted: action.payload };
default: default:
return state; return state;
} }
@@ -96,6 +107,10 @@ interface CheckoutWizardProviderProps {
initialStep?: CheckoutStepId; initialStep?: CheckoutStepId;
initialAuthUser?: any; initialAuthUser?: any;
initialIsAuthenticated?: boolean; initialIsAuthenticated?: boolean;
paddle?: {
environment?: string | null;
client_token?: string | null;
} | null;
} }
export function CheckoutWizardProvider({ export function CheckoutWizardProvider({
@@ -104,7 +119,8 @@ export function CheckoutWizardProvider({
packageOptions, packageOptions,
initialStep, initialStep,
initialAuthUser, initialAuthUser,
initialIsAuthenticated initialIsAuthenticated,
paddle,
}: CheckoutWizardProviderProps) { }: CheckoutWizardProviderProps) {
const customInitialState: CheckoutState = { const customInitialState: CheckoutState = {
...initialState, ...initialState,
@@ -124,7 +140,8 @@ export function CheckoutWizardProvider({
if (savedState) { if (savedState) {
try { try {
const parsed = JSON.parse(savedState); const parsed = JSON.parse(savedState);
if (parsed.selectedPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') { const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.paddle_price_id === 'string' && parsed.selectedPackage.paddle_price_id !== '';
if (hasValidPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') {
// Restore state selectively // Restore state selectively
if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage }); if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
if (parsed.currentStep) dispatch({ type: 'GO_TO_STEP', payload: parsed.currentStep }); if (parsed.currentStep) dispatch({ type: 'GO_TO_STEP', payload: parsed.currentStep });
@@ -192,6 +209,11 @@ export function CheckoutWizardProvider({
dispatch({ type: 'UPDATE_PAYMENT_INTENT', payload: null }); dispatch({ type: 'UPDATE_PAYMENT_INTENT', payload: null });
dispatch({ type: 'SET_LOADING', payload: false }); dispatch({ type: 'SET_LOADING', payload: false });
dispatch({ type: 'SET_ERROR', payload: null }); dispatch({ type: 'SET_ERROR', payload: null });
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false });
}, []);
const setPaymentCompleted = useCallback((completed: boolean) => {
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: completed });
}, []); }, []);
const cancelCheckout = useCallback(() => { const cancelCheckout = useCallback(() => {
@@ -226,6 +248,8 @@ export function CheckoutWizardProvider({
currentStep: state.currentStep, currentStep: state.currentStep,
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
authUser: state.authUser, authUser: state.authUser,
paddleConfig: paddle ?? null,
paymentCompleted: state.paymentCompleted,
selectPackage, selectPackage,
setSelectedPackage, setSelectedPackage,
setAuthUser, setAuthUser,
@@ -238,6 +262,7 @@ export function CheckoutWizardProvider({
setLoading, setLoading,
setError, setError,
resetPaymentState, resetPaymentState,
setPaymentCompleted,
}; };
return ( return (

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
import { CheckoutWizard } from '../CheckoutWizard';
import { useCheckoutWizard } from '../WizardContext';
vi.mock('@/hooks/useAnalytics', () => ({
useAnalytics: () => ({ trackEvent: vi.fn() }),
}));
vi.mock('../steps/PackageStep', () => ({
PackageStep: () => <div data-testid="package-step" />,
}));
vi.mock('../steps/AuthStep', () => ({
AuthStep: () => <div data-testid="auth-step" />,
}));
vi.mock('../steps/PaymentStep', () => ({
PaymentStep: () => {
const { setPaymentCompleted } = useCheckoutWizard();
return (
<div data-testid="payment-step">
<button type="button" onClick={() => setPaymentCompleted(true)}>
mark-complete
</button>
</div>
);
},
}));
vi.mock('../steps/ConfirmationStep', () => ({
ConfirmationStep: () => <div data-testid="confirmation-step" />,
}));
const basePackage = {
id: 1,
name: 'Starter',
description: 'Test package',
price: 0,
type: 'endcustomer' as const,
features: [],
};
describe('CheckoutWizard auth step navigation guard', () => {
beforeAll(() => {
// jsdom does not implement scrollTo, but the wizard calls it on step changes.
Object.defineProperty(window, 'scrollTo', { value: vi.fn(), writable: true });
});
afterEach(() => {
cleanup();
});
it('disables the next button when the user is not authenticated on the auth step', () => {
render(
<CheckoutWizard
initialPackage={basePackage}
packageOptions={[basePackage]}
privacyHtml="<p>privacy</p>"
initialAuthUser={null}
initialStep="auth"
/>,
);
const nextButton = screen.getByRole('button', { name: 'checkout.next' });
expect(nextButton).toBeDisabled();
});
it('enables the next button once the user is authenticated on the auth step', () => {
render(
<CheckoutWizard
initialPackage={basePackage}
packageOptions={[basePackage]}
privacyHtml="<p>privacy</p>"
initialAuthUser={{ id: 42, email: 'user@example.com' }}
initialStep="auth"
/>,
);
const nextButton = screen.getByRole('button', { name: 'checkout.next' });
expect(nextButton).not.toBeDisabled();
});
it('only renders the next button on the payment step after the payment is completed', async () => {
const paidPackage = { ...basePackage, id: 2, price: 99 };
render(
<CheckoutWizard
initialPackage={paidPackage}
packageOptions={[paidPackage]}
privacyHtml="<p>privacy</p>"
initialAuthUser={{ id: 42, email: 'user@example.com' }}
initialStep="payment"
/>,
);
await screen.findByTestId('payment-step');
expect(screen.queryByRole('button', { name: 'checkout.next' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'mark-complete' }));
expect(await screen.findByRole('button', { name: 'checkout.next' })).toBeEnabled();
});
});

View File

@@ -3,6 +3,7 @@ import { usePage } from "@inertiajs/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext"; import { useCheckoutWizard } from "../WizardContext";
import type { GoogleProfilePrefill } from '../types';
import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm"; import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm"; import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -11,6 +12,8 @@ import { LoaderCircle } from "lucide-react";
interface AuthStepProps { interface AuthStepProps {
privacyHtml: string; privacyHtml: string;
googleProfile?: GoogleProfilePrefill;
onClearGoogleProfile?: () => void;
} }
type GoogleAuthFlash = { type GoogleAuthFlash = {
@@ -29,7 +32,7 @@ const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
</svg> </svg>
); );
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => { export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const page = usePage<{ locale?: string }>(); const page = usePage<{ locale?: string }>();
const locale = page.props.locale ?? "de"; const locale = page.props.locale ?? "de";
@@ -42,10 +45,11 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false); const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
useEffect(() => { useEffect(() => {
if (googleAuth?.status === 'success') { if (googleAuth?.status === 'signin') {
toast.success(t('checkout.auth_step.google_success_toast')); toast.success(t('checkout.auth_step.google_success_toast'));
onClearGoogleProfile?.();
} }
}, [googleAuth?.status, t]); }, [googleAuth?.status, onClearGoogleProfile, t]);
useEffect(() => { useEffect(() => {
if (googleAuth?.error) { if (googleAuth?.error) {
@@ -64,6 +68,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
name: payload.name ?? undefined, name: payload.name ?? undefined,
pending_purchase: Boolean(payload.pending_purchase), pending_purchase: Boolean(payload.pending_purchase),
}); });
onClearGoogleProfile?.();
nextStep(); nextStep();
}; };
@@ -78,6 +83,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
}); });
} }
onClearGoogleProfile?.();
nextStep(); nextStep();
}; };
@@ -158,6 +164,8 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
privacyHtml={privacyHtml} privacyHtml={privacyHtml}
locale={locale} locale={locale}
onSuccess={handleRegisterSuccess} onSuccess={handleRegisterSuccess}
prefill={googleProfile}
onClearGoogleProfile={onClearGoogleProfile}
/> />
) )
) : ( ) : (

View File

@@ -1,401 +1,332 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { LoaderCircle } from 'lucide-react'; import { LoaderCircle } from 'lucide-react';
import { useCheckoutWizard } from '../WizardContext'; import { useCheckoutWizard } from '../WizardContext';
import { getStripe } from '@/utils/stripe';
interface PaymentStepProps { type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
stripePublishableKey: string;
paypalClientId: string;
}
type Provider = 'stripe' | 'paypal'; declare global {
type PaymentStatus = 'idle' | 'loading' | 'ready' | 'processing' | 'error' | 'success'; interface Window {
Paddle?: {
interface StripePaymentFormProps { Environment?: {
onProcessing: () => void; set: (environment: string) => void;
onSuccess: () => void;
onError: (message: string) => void;
selectedPackage: any;
t: (key: string, options?: Record<string, unknown>) => string;
}
const StripePaymentForm: React.FC<StripePaymentFormProps> = ({ onProcessing, onSuccess, onError, selectedPackage, t }) => {
const stripe = useStripe();
const elements = useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!stripe || !elements) {
const message = t('checkout.payment_step.stripe_not_loaded');
onError(message);
return;
}
onProcessing();
setIsProcessing(true);
setErrorMessage('');
try {
const { error: stripeError, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/success`,
},
redirect: 'if_required',
});
if (stripeError) {
let message = t('checkout.payment_step.payment_failed');
switch (stripeError.type) {
case 'card_error':
message += stripeError.message || t('checkout.payment_step.error_card');
break;
case 'validation_error':
message += t('checkout.payment_step.error_validation');
break;
case 'api_connection_error':
message += t('checkout.payment_step.error_connection');
break;
case 'api_error':
message += t('checkout.payment_step.error_server');
break;
case 'authentication_error':
message += t('checkout.payment_step.error_auth');
break;
default:
message += stripeError.message || t('checkout.payment_step.error_unknown');
}
setErrorMessage(message);
onError(message);
return;
}
if (paymentIntent && paymentIntent.status === 'succeeded') {
onSuccess();
return;
}
onError(t('checkout.payment_step.unexpected_status', { status: paymentIntent?.status }));
} catch (error) {
console.error('Stripe payment failed', error);
onError(t('checkout.payment_step.error_unknown'));
} finally {
setIsProcessing(false);
}
}; };
Initialize?: (options: { token: string }) => void;
return ( Checkout: {
<form onSubmit={handleSubmit} className="space-y-4"> open: (options: Record<string, unknown>) => void;
{errorMessage && (
<Alert variant="destructive">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.secure_payment_desc')}</p>
<PaymentElement />
<Button type="submit" disabled={!stripe || isProcessing} size="lg" className="w-full">
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t('checkout.payment_step.pay_now', { price: selectedPackage?.price || 0 })}
</Button>
</div>
</form>
);
}; };
};
interface PayPalPaymentFormProps { }
onProcessing: () => void;
onSuccess: () => void;
onError: (message: string) => void;
selectedPackage: any;
isReseller: boolean;
paypalPlanId?: string | null;
t: (key: string, options?: Record<string, unknown>) => string;
} }
const PayPalPaymentForm: React.FC<PayPalPaymentFormProps> = ({ onProcessing, onSuccess, onError, selectedPackage, isReseller, paypalPlanId, t }) => { const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js';
const createOrder = async () => {
if (!selectedPackage?.id) { type PaddleEnvironment = 'sandbox' | 'production';
const message = t('checkout.payment_step.paypal_order_error');
onError(message); let paddleLoaderPromise: Promise<typeof window.Paddle | null> | null = null;
throw new Error(message);
function configurePaddle(paddle: typeof window.Paddle | undefined | null, environment: PaddleEnvironment): typeof window.Paddle | null {
if (!paddle) {
return null;
} }
try { try {
onProcessing(); paddle.Environment?.set?.(environment);
const endpoint = isReseller ? '/paypal/create-subscription' : '/paypal/create-order';
const payload: Record<string, unknown> = {
package_id: selectedPackage.id,
};
if (isReseller) {
if (!paypalPlanId) {
const message = t('checkout.payment_step.paypal_missing_plan');
onError(message);
throw new Error(message);
}
payload.plan_id = paypalPlanId;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (response.ok) {
const orderId = isReseller ? data.order_id : data.id;
if (typeof orderId === 'string' && orderId.length > 0) {
return orderId;
}
} else {
onError(data.error || t('checkout.payment_step.paypal_order_error'));
}
throw new Error('Failed to create PayPal order');
} catch (error) { } catch (error) {
console.error('PayPal create order failed', error); console.warn('[Paddle] Failed to set environment', error);
onError(t('checkout.payment_step.network_error'));
throw error;
} }
};
const onApprove = async (data: any) => { return paddle;
try { }
const response = await fetch('/paypal/capture-order', {
method: 'POST', async function loadPaddle(environment: PaddleEnvironment): Promise<typeof window.Paddle | null> {
headers: { if (typeof window === 'undefined') {
'Content-Type': 'application/json', return null;
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', }
},
body: JSON.stringify({ order_id: data.orderID }), if (window.Paddle) {
return configurePaddle(window.Paddle, environment);
}
if (!paddleLoaderPromise) {
paddleLoaderPromise = new Promise<typeof window.Paddle | null>((resolve, reject) => {
const script = document.createElement('script');
script.src = PADDLE_SCRIPT_URL;
script.async = true;
script.onload = () => resolve(window.Paddle ?? null);
script.onerror = (error) => reject(error);
document.head.appendChild(script);
}).catch((error) => {
console.error('Failed to load Paddle.js', error);
paddleLoaderPromise = null;
return null;
}); });
const result = await response.json();
if (response.ok && result.status === 'captured') {
onSuccess();
} else {
onError(result.error || t('checkout.payment_step.paypal_capture_error'));
} }
} catch (error) {
console.error('PayPal capture failed', error); const paddle = await paddleLoaderPromise;
onError(t('checkout.payment_step.network_error'));
return configurePaddle(paddle, environment);
} }
};
const handleError = (error: unknown) => { const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean }> = ({ onCheckout, disabled, isProcessing }) => {
console.error('PayPal error', error);
onError(t('checkout.payment_step.paypal_error'));
};
const handleCancel = () => {
onError(t('checkout.payment_step.paypal_cancelled'));
};
return (
<div className="rounded-lg border bg-card p-6 shadow-sm">
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.secure_paypal_desc') || 'Bezahlen Sie sicher mit PayPal.'}</p>
<PayPalButtons
style={{ layout: 'vertical' }}
createOrder={async () => createOrder()}
onApprove={onApprove}
onError={handleError}
onCancel={handleCancel}
/>
</div>
);
};
const statusVariantMap: Record<PaymentStatus, 'default' | 'destructive' | 'success' | 'secondary'> = {
idle: 'secondary',
loading: 'secondary',
ready: 'secondary',
processing: 'secondary',
error: 'destructive',
success: 'success',
};
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey, paypalClientId }) => {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { selectedPackage, authUser, nextStep, resetPaymentState } = useCheckoutWizard();
const [paymentMethod, setPaymentMethod] = useState<Provider>('stripe'); return (
const [clientSecret, setClientSecret] = useState(''); <Button size="lg" className="w-full" disabled={disabled} onClick={onCheckout}>
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t('checkout.payment_step.pay_with_paddle')}
</Button>
);
};
export const PaymentStep: React.FC = () => {
const { t } = useTranslation('marketing');
const { selectedPackage, nextStep, paddleConfig, authUser, setPaymentCompleted } = useCheckoutWizard();
const [status, setStatus] = useState<PaymentStatus>('idle'); const [status, setStatus] = useState<PaymentStatus>('idle');
const [statusDetail, setStatusDetail] = useState<string>(''); const [message, setMessage] = useState<string>('');
const [intentRefreshKey, setIntentRefreshKey] = useState(0); const [initialised, setInitialised] = useState(false);
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null); const [inlineActive, setInlineActive] = useState(false);
const paddleRef = useRef<typeof window.Paddle | null>(null);
const eventCallbackRef = useRef<(event: any) => void>();
const checkoutContainerClass = 'paddle-checkout-container';
const stripePromise = useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]); const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
const isReseller = selectedPackage?.type === 'reseller';
const paypalPlanId = useMemo(() => { const handleFreeActivation = async () => {
if (!selectedPackage) { setPaymentCompleted(true);
return null; nextStep();
} };
if (typeof selectedPackage.paypal_plan_id === 'string' && selectedPackage.paypal_plan_id.trim().length > 0) {
return selectedPackage.paypal_plan_id;
}
const metadata = (selectedPackage as Record<string, unknown>)?.metadata;
if (metadata && typeof metadata === 'object') {
const value = (metadata as Record<string, unknown>).paypal_plan_id;
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return null;
}, [selectedPackage]);
const paypalDisabled = isReseller && !paypalPlanId;
useEffect(() => {
setStatus('idle');
setStatusDetail('');
setClientSecret('');
setProcessingProvider(null);
}, [selectedPackage?.id]);
useEffect(() => {
if (isFree) {
resetPaymentState();
setStatus('ready');
setStatusDetail('');
return;
}
const startPaddleCheckout = async () => {
if (!selectedPackage) { if (!selectedPackage) {
return; return;
} }
if (paymentMethod === 'paypal') { if (!selectedPackage.paddle_price_id) {
if (paypalDisabled) {
setStatus('error'); setStatus('error');
setStatusDetail(t('checkout.payment_step.paypal_missing_plan')); setMessage(t('checkout.payment_step.paddle_not_configured'));
} else {
setStatus('ready');
setStatusDetail('');
}
return; return;
} }
if (!stripePromise) { setPaymentCompleted(false);
setStatus('error'); setStatus('processing');
setStatusDetail(t('checkout.payment_step.stripe_not_loaded')); setMessage(t('checkout.payment_step.paddle_preparing'));
return; setInlineActive(false);
}
if (!authUser) {
setStatus('error');
setStatusDetail(t('checkout.payment_step.auth_required'));
return;
}
let cancelled = false;
setStatus('loading');
setStatusDetail(t('checkout.payment_step.status_loading'));
setClientSecret('');
const loadIntent = async () => {
try { try {
const response = await fetch('/stripe/create-payment-intent', { const inlineSupported = initialised && !!paddleConfig?.client_token;
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.info('[Checkout] Paddle inline status', {
inlineSupported,
initialised,
hasClientToken: Boolean(paddleConfig?.client_token),
environment: paddleConfig?.environment,
paddlePriceId: selectedPackage.paddle_price_id,
});
}
if (inlineSupported) {
const paddle = paddleRef.current;
if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') {
throw new Error('Inline Paddle checkout is not available.');
}
const inlinePayload: Record<string, unknown> = {
items: [
{
priceId: selectedPackage.paddle_price_id,
quantity: 1,
},
],
settings: {
displayMode: 'inline',
frameTarget: checkoutContainerClass,
frameInitialHeight: '550',
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
theme: 'light',
locale: typeof document !== 'undefined' ? document.documentElement.lang ?? 'de' : 'de',
},
customData: {
package_id: String(selectedPackage.id),
},
};
const customerEmail = authUser?.email ?? null;
if (customerEmail) {
inlinePayload.customer = { email: customerEmail };
}
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.info('[Checkout] Opening inline Paddle checkout', inlinePayload);
}
paddle.Checkout.open(inlinePayload);
setInlineActive(true);
setStatus('ready');
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
return;
}
const response = await fetch('/paddle/create-checkout', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
}, },
body: JSON.stringify({ package_id: selectedPackage.id }), body: JSON.stringify({
package_id: selectedPackage.id,
}),
}); });
const data = await response.json(); const rawBody = await response.text();
if (typeof window !== 'undefined') {
if (!response.ok || !data.client_secret) { // eslint-disable-next-line no-console
const message = data.error || t('checkout.payment_step.payment_intent_error'); console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
if (!cancelled) {
setStatus('error');
setStatusDetail(message);
}
return;
} }
if (!cancelled) { let data: any = null;
setClientSecret(data.client_secret); try {
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
} catch (parseError) {
console.warn('Failed to parse Paddle checkout payload as JSON', parseError);
data = null;
}
let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null;
if (!checkoutUrl) {
const trimmed = rawBody.trim();
if (/^https?:\/\//i.test(trimmed)) {
checkoutUrl = trimmed;
} else if (trimmed.startsWith('<')) {
const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:\/?#\[\]@!$&'()*+,;=%-]+/);
if (match) {
checkoutUrl = match[0];
}
}
}
if (!response.ok || !checkoutUrl) {
const message = data?.message || rawBody || 'Unable to create Paddle checkout.';
throw new Error(message);
}
window.open(checkoutUrl, '_blank', 'noopener');
setInlineActive(false);
setStatus('ready'); setStatus('ready');
setStatusDetail(t('checkout.payment_step.status_ready')); setMessage(t('checkout.payment_step.paddle_ready'));
}
} catch (error) { } catch (error) {
if (!cancelled) { console.error('Failed to start Paddle checkout', error);
console.error('Failed to load payment intent', error);
setStatus('error'); setStatus('error');
setStatusDetail(t('checkout.payment_step.network_error')); setMessage(t('checkout.payment_step.paddle_error'));
} setInlineActive(false);
setPaymentCompleted(false);
} }
}; };
loadIntent(); useEffect(() => {
let cancelled = false;
const environment = paddleConfig?.environment === 'sandbox' ? 'sandbox' : 'production';
const clientToken = paddleConfig?.client_token ?? null;
eventCallbackRef.current = (event) => {
if (!event?.name) {
return;
}
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.debug('[Checkout] Paddle event', event);
}
if (event.name === 'checkout.completed') {
setStatus('ready');
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
setInlineActive(false);
setPaymentCompleted(true);
}
if (event.name === 'checkout.closed') {
setStatus('idle');
setMessage('');
setInlineActive(false);
setPaymentCompleted(false);
}
if (event.name === 'checkout.error') {
setStatus('error');
setMessage(t('checkout.payment_step.paddle_error'));
setInlineActive(false);
setPaymentCompleted(false);
}
};
(async () => {
const paddle = await loadPaddle(environment);
if (cancelled || !paddle) {
return;
}
try {
let inlineReady = false;
if (typeof paddle.Initialize === 'function' && clientToken) {
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.info('[Checkout] Initializing Paddle.js', { environment, hasToken: Boolean(clientToken) });
}
paddle.Initialize({
token: clientToken,
checkout: {
settings: {
displayMode: 'inline',
frameTarget: checkoutContainerClass,
frameInitialHeight: '550',
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
locale: typeof document !== 'undefined' ? document.documentElement.lang ?? 'de' : 'de',
},
},
eventCallback: (event: any) => eventCallbackRef.current?.(event),
});
inlineReady = true;
}
paddleRef.current = paddle;
setInitialised(inlineReady);
} catch (error) {
console.error('Failed to initialize Paddle', error);
setInitialised(false);
setStatus('error');
setMessage(t('checkout.payment_step.paddle_error'));
setPaymentCompleted(false);
}
})();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, stripePromise, t]); }, [paddleConfig?.environment, paddleConfig?.client_token, setPaymentCompleted, t]);
const providerLabel = useCallback((provider: Provider) => { useEffect(() => {
switch (provider) { setPaymentCompleted(false);
case 'paypal': }, [selectedPackage?.id, setPaymentCompleted]);
return 'PayPal';
default: if (!selectedPackage) {
return 'Stripe'; return (
<Alert variant="destructive">
<AlertTitle>{t('checkout.payment_step.no_package_title')}</AlertTitle>
<AlertDescription>{t('checkout.payment_step.no_package_description')}</AlertDescription>
</Alert>
);
} }
}, []);
const handleProcessing = useCallback((provider: Provider) => {
setProcessingProvider(provider);
setStatus('processing');
setStatusDetail(t('checkout.payment_step.status_processing', { provider: providerLabel(provider) }));
}, [providerLabel, t]);
const handleSuccess = useCallback((provider: Provider) => {
setProcessingProvider(provider);
setStatus('success');
setStatusDetail(t('checkout.payment_step.status_success'));
setTimeout(() => nextStep(), 600);
}, [nextStep, t]);
const handleError = useCallback((provider: Provider, message: string) => {
setProcessingProvider(provider);
setStatus('error');
setStatusDetail(message);
}, []);
const handleRetry = () => {
if (paymentMethod === 'stripe') {
setIntentRefreshKey((key) => key + 1);
}
setStatus('idle');
setStatusDetail('');
setProcessingProvider(null);
};
if (isFree) { if (isFree) {
return ( return (
@@ -405,7 +336,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription> <AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
</Alert> </Alert>
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="lg" onClick={nextStep}> <Button size="lg" onClick={handleFreeActivation}>
{t('checkout.payment_step.activate_package')} {t('checkout.payment_step.activate_package')}
</Button> </Button>
</div> </div>
@@ -413,94 +344,44 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
); );
} }
const renderStatusAlert = () => {
if (status === 'idle') {
return null;
}
const variant = statusVariantMap[status];
return (
<Alert variant={variant}>
<AlertTitle>
{status === 'error'
? t('checkout.payment_step.status_error_title')
: status === 'success'
? t('checkout.payment_step.status_success_title')
: t('checkout.payment_step.status_info_title')}
</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-4">
<span>{statusDetail}</span>
{status === 'processing' && <LoaderCircle className="h-4 w-4 animate-spin" />}
{status === 'error' && (
<Button size="sm" variant="outline" onClick={handleRetry}>
{t('checkout.payment_step.status_retry')}
</Button>
)}
</AlertDescription>
</Alert>
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap gap-3"> <p className="text-sm text-muted-foreground">
<Button {t('checkout.payment_step.paddle_intro')}
variant={paymentMethod === 'stripe' ? 'default' : 'outline'} </p>
onClick={() => setPaymentMethod('stripe')}
disabled={paymentMethod === 'stripe'}
>
{t('checkout.payment_step.method_stripe')}
</Button>
<Button
variant={paymentMethod === 'paypal' ? 'default' : 'outline'}
onClick={() => setPaymentMethod('paypal')}
disabled={paypalDisabled || paymentMethod === 'paypal'}
>
{t('checkout.payment_step.method_paypal')}
</Button>
</div>
{renderStatusAlert()} {status !== 'idle' && (
<Alert variant={status === 'error' ? 'destructive' : 'secondary'}>
{paymentMethod === 'stripe' && clientSecret && stripePromise && ( <AlertTitle>
<Elements stripe={stripePromise} options={{ clientSecret }}> {status === 'processing'
<StripePaymentForm ? t('checkout.payment_step.status_processing_title')
selectedPackage={selectedPackage} : status === 'ready'
onProcessing={() => handleProcessing('stripe')} ? t('checkout.payment_step.status_ready_title')
onSuccess={() => handleSuccess('stripe')} : status === 'error'
onError={(message) => handleError('stripe', message)} ? t('checkout.payment_step.status_error_title')
t={t} : t('checkout.payment_step.status_info_title')}
/> </AlertTitle>
</Elements> <AlertDescription className="flex items-center gap-3">
)} <span>{message}</span>
{status === 'processing' && <LoaderCircle className="h-4 w-4 animate-spin" />}
{paymentMethod === 'stripe' && !clientSecret && status === 'loading' && ( </AlertDescription>
<div className="rounded-lg border bg-card p-6 text-sm text-muted-foreground shadow-sm">
<LoaderCircle className="mb-3 h-4 w-4 animate-spin" />
{t('checkout.payment_step.status_loading')}
</div>
)}
{paymentMethod === 'paypal' && !paypalDisabled && (
<PayPalScriptProvider options={{ clientId: paypalClientId, currency: 'EUR' }}>
<PayPalPaymentForm
isReseller={Boolean(isReseller)}
onProcessing={() => handleProcessing('paypal')}
onSuccess={() => handleSuccess('paypal')}
onError={(message) => handleError('paypal', message)}
paypalPlanId={paypalPlanId}
selectedPackage={selectedPackage}
t={t}
/>
</PayPalScriptProvider>
)}
{paymentMethod === 'paypal' && paypalDisabled && (
<Alert variant="destructive">
<AlertDescription>{t('checkout.payment_step.paypal_missing_plan')}</AlertDescription>
</Alert> </Alert>
)} )}
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
<div className={`${checkoutContainerClass} min-h-[200px]`} />
{!inlineActive && (
<PaddleCta
onCheckout={startPaddleCheckout}
disabled={status === 'processing'}
isProcessing={status === 'processing'}
/>
)}
<p className="text-xs text-muted-foreground">
{t('checkout.payment_step.paddle_disclaimer')}
</p>
</div>
</div> </div>
); );
}; };

View File

@@ -1,5 +1,14 @@
export type CheckoutStepId = 'package' | 'auth' | 'payment' | 'confirmation'; export type CheckoutStepId = 'package' | 'auth' | 'payment' | 'confirmation';
export interface GoogleProfilePrefill {
email?: string;
name?: string;
given_name?: string;
family_name?: string;
avatar?: string;
locale?: string;
}
export interface CheckoutPackage { export interface CheckoutPackage {
id: number; id: number;
name: string; name: string;
@@ -15,7 +24,8 @@ export interface CheckoutPackage {
type: 'endcustomer' | 'reseller'; type: 'endcustomer' | 'reseller';
features: string[]; features: string[];
limits?: Record<string, unknown>; limits?: Record<string, unknown>;
paypal_plan_id?: string | null; paddle_price_id?: string | null;
paddle_product_id?: string | null;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -30,7 +40,7 @@ export interface CheckoutWizardState {
name?: string; name?: string;
pending_purchase?: boolean; pending_purchase?: boolean;
} | null; } | null;
paymentProvider?: 'stripe' | 'paypal'; paymentProvider?: 'stripe' | 'paddle';
isProcessing?: boolean; isProcessing?: boolean;
} }

View File

@@ -12,14 +12,14 @@ return [
'contact' => 'Kontakt', 'contact' => 'Kontakt',
'vat_id' => 'Umsatzsteuer-ID: DE123456789', 'vat_id' => 'Umsatzsteuer-ID: DE123456789',
'monetization' => 'Monetarisierung', 'monetization' => 'Monetarisierung',
'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de', 'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. Preise exkl. MwSt. Support: support@fotospiel.de',
'register_court' => 'Registergericht: Amtsgericht Musterstadt', 'register_court' => 'Registergericht: Amtsgericht Musterstadt',
'commercial_register' => 'Handelsregister: HRB 12345', 'commercial_register' => 'Handelsregister: HRB 12345',
'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.', 'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.',
'responsible' => 'Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt', 'responsible' => 'Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt',
'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.', 'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.',
'payments' => 'Zahlungen und Packages', 'payments' => 'Zahlungen und Packages',
'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert alle Daten werden verschlüsselt übertragen.', 'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsinformationen werden sicher und verschlüsselt durch Paddle als Merchant of Record verarbeitet.',
'data_retention' => 'Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.', 'data_retention' => 'Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.',
'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.', 'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.',
'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.', 'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.',
@@ -34,5 +34,4 @@ return [
'version' => 'Version :version', 'version' => 'Version :version',
'and' => 'und', 'and' => 'und',
'stripe_privacy' => 'Stripe Datenschutz', 'stripe_privacy' => 'Stripe Datenschutz',
'paypal_privacy' => 'PayPal Datenschutz',
]; ];

View File

@@ -67,7 +67,7 @@
"faq_q3": "Was passiert bei Ablauf?", "faq_q3": "Was passiert bei Ablauf?",
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.", "faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
"faq_q4": "Zahlungssicher?", "faq_q4": "Zahlungssicher?",
"faq_a4": "Ja, via Stripe oder PayPal sicher und GDPR-konform.", "faq_a4": "Ja, via Paddle sicher und GDPR-konform.",
"final_cta": "Bereit für Ihr nächstes Event?", "final_cta": "Bereit für Ihr nächstes Event?",
"contact_us": "Kontaktieren Sie uns", "contact_us": "Kontaktieren Sie uns",
"feature_live_slideshow": "Live-Slideshow", "feature_live_slideshow": "Live-Slideshow",

View File

@@ -30,7 +30,7 @@ return [
'faq_q3' => 'Was passiert bei Ablauf?', 'faq_q3' => 'Was passiert bei Ablauf?',
'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.', 'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.',
'faq_q4' => 'Zahlungssicher?', 'faq_q4' => 'Zahlungssicher?',
'faq_a4' => 'Ja, via Stripe oder PayPal sicher und GDPR-konform.', 'faq_a4' => 'Ja, via Paddle sicher und GDPR-konform.',
'final_cta' => 'Bereit für Ihr nächstes Event?', 'final_cta' => 'Bereit für Ihr nächstes Event?',
'contact_us' => 'Kontaktieren Sie uns', 'contact_us' => 'Kontaktieren Sie uns',
'feature_live_slideshow' => 'Live-Slideshow', 'feature_live_slideshow' => 'Live-Slideshow',
@@ -60,10 +60,12 @@ return [
'max_guests_label' => 'Max. Gäste', 'max_guests_label' => 'Max. Gäste',
'gallery_days_label' => 'Galerie-Tage', 'gallery_days_label' => 'Galerie-Tage',
'feature_overview' => 'Feature-Überblick', 'feature_overview' => 'Feature-Überblick',
'order_hint' => 'Sofort startklar keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.', 'order_hint' => 'Sofort startklar keine versteckten Kosten, sichere Zahlung über Paddle.',
'features_label' => 'Features', 'features_label' => 'Features',
'breakdown_label' => 'Leistungsübersicht', 'breakdown_label' => 'Leistungsübersicht',
'limits_label' => 'Limits & Kapazitäten', 'limits_label' => 'Limits & Kapazitäten',
'paddle_not_configured' => 'Dieses Package ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.',
'paddle_checkout_failed' => 'Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.',
], ],
'nav' => [ 'nav' => [
'home' => 'Startseite', 'home' => 'Startseite',

View File

@@ -12,14 +12,14 @@ return [
'contact' => 'Contact', 'contact' => 'Contact',
'vat_id' => 'VAT ID: DE123456789', 'vat_id' => 'VAT ID: DE123456789',
'monetization' => 'Monetization', 'monetization' => 'Monetization',
'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Stripe and PayPal. Prices excl. VAT. Support: support@fotospiel.de', 'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Paddle. Prices excl. VAT. Support: support@fotospiel.de',
'register_court' => 'Register Court: District Court Musterstadt', 'register_court' => 'Register Court: District Court Musterstadt',
'commercial_register' => 'Commercial Register: HRB 12345', 'commercial_register' => 'Commercial Register: HRB 12345',
'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.', 'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.',
'responsible' => 'Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt', 'responsible' => 'Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt',
'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.', 'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.',
'payments' => 'Payments and Packages', 'payments' => 'Payments and Packages',
'payments_desc' => 'We process payments for Packages via Stripe and PayPal. Card information is not stored all data is transmitted encrypted. See Stripe Privacy and PayPal Privacy.', 'payments_desc' => 'We process payments for Packages via Paddle. Payment information is handled securely and encrypted by Paddle as the merchant of record.',
'data_retention' => 'Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.', 'data_retention' => 'Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.',
'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.', 'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.',
'cookies' => 'Cookies: Only functional cookies for the PWA.', 'cookies' => 'Cookies: Only functional cookies for the PWA.',

Some files were not shown because too many files have changed in this diff Show More