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_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_KEY_STORE=
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).
- 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).
- 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.
- Payment Systems: Stripe (subscriptions and one-time payments), PayPal (integrated payments), RevenueCat (mobile app subscriptions).
- 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: 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.
## 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: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.
- Payment Integration: Stripe webhooks, PayPal API integration, RevenueCat mobile subscriptions.
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
## PWA Architecture
- 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;
use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle;
use App\Models\Package;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
@@ -11,21 +15,23 @@ use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Schemas\Components\Section;
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\Toggle;
use Filament\Notifications\Notification;
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\Tables;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use UnitEnum;
use BackedEnum;
class PackageResource extends Resource
{
@@ -150,6 +156,28 @@ class PackageResource extends Resource
->columnSpanFull()
->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')
->wrap()
->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([
Tables\Filters\SelectFilter::make('type')
@@ -224,6 +274,35 @@ class PackageResource extends Resource
]),
])
->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(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Resources;
use App\Filament\Resources\PurchaseResource\Pages;
use App\Models\PackagePurchase;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
@@ -11,23 +12,19 @@ use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Log;
use BackedEnum;
use UnitEnum;
class PurchaseResource extends Resource
{
protected static ?string $model = PackagePurchase::class;
@@ -97,7 +94,7 @@ class PurchaseResource extends Resource
->columns([
BadgeColumn::make('type')
->label('Type')
->color(fn (string $state): string => match($state) {
->color(fn (string $state): string => match ($state) {
'endcustomer_event' => 'info',
'reseller_subscription' => 'success',
default => 'gray',
@@ -164,11 +161,11 @@ class PurchaseResource extends Resource
->color('danger')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (PackagePurchase $record): bool => !$record->refunded)
->visible(fn (PackagePurchase $record): bool => ! $record->refunded)
->action(function (PackagePurchase $record) {
$record->update(['refunded' => true]);
// TODO: Call Stripe/PayPal API for actual refund
Log::info('Refund processed for purchase ID: ' . $record->id);
// TODO: Call Stripe/Paddle API for actual refund
Log::info('Refund processed for purchase ID: '.$record->id);
}),
])
->bulkActions([

View File

@@ -20,10 +20,10 @@ class ViewPurchase extends ViewRecord
->color('danger')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn ($record): bool => !$record->refunded)
->visible(fn ($record): bool => ! $record->refunded)
->action(function ($record) {
$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;
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\TenantPackagesRelationManager;
use Filament\Resources\RelationManagers\RelationGroup;
use Filament\Notifications\Notification;
use UnitEnum;
use App\Models\Tenant;
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 UnitEnum;
class TenantResource extends Resource
{
protected static ?string $model = Tenant::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-building-office';
protected static UnitEnum|string|null $navigationGroup = null;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform_management');
}
protected static ?int $navigationSort = 10;
public static function form(Schema $form): Schema
@@ -61,6 +60,11 @@ class TenantResource extends Resource
->label(__('admin.tenants.fields.event_credits_balance'))
->numeric()
->readOnly(),
TextInput::make('paddle_customer_id')
->label('Paddle Customer ID')
->maxLength(191)
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
->nullable(),
TextInput::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue'))
->prefix('€')
@@ -104,6 +108,10 @@ class TenantResource extends Resource
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('paddle_customer_id')
->label('Paddle Customer')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
Tables\Columns\TextColumn::make('event_credits_balance')
->label(__('admin.tenants.fields.event_credits_balance'))
->badge()
@@ -162,6 +170,7 @@ class TenantResource extends Resource
\App\Models\PackagePurchase::create([
'tenant_id' => $record->id,
'package_id' => $data['package_id'],
'provider' => 'manual',
'provider_id' => 'manual',
'type' => 'reseller_subscription',
'price' => 0,

View File

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

View File

@@ -2,24 +2,21 @@
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\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
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\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class TenantPackagesRelationManager extends RelationManager
{
@@ -40,6 +37,11 @@ class TenantPackagesRelationManager extends RelationManager
DateTimePicker::make('expires_at')
->label('Ablaufdatum')
->required(),
TextInput::make('paddle_subscription_id')
->label('Paddle Subscription ID')
->maxLength(191)
->helperText('Abonnement-ID aus Paddle Billing.')
->nullable(),
Toggle::make('active')
->label('Aktiv'),
Textarea::make('reason')
@@ -70,6 +72,10 @@ class TenantPackagesRelationManager extends RelationManager
TextColumn::make('expires_at')
->dateTime()
->sortable(),
TextColumn::make('paddle_subscription_id')
->label('Paddle Subscription')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
IconColumn::make('active')
->boolean()
->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\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
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
{
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
public function index(Request $request): JsonResponse
{
$type = $request->query('type', 'endcustomer');
@@ -51,7 +47,7 @@ class PackageController extends Controller
$request->validate([
'package_id' => 'required|exists:packages,id',
'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
]);
@@ -105,8 +101,8 @@ class PackageController extends Controller
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'payment_method_id' => 'required_without:paypal_order_id|string',
'paypal_order_id' => 'required_without:payment_method_id|string',
'payment_method_id' => 'required_without:paddle_transaction_id|string',
'paddle_transaction_id' => 'required_without:payment_method_id|string',
]);
$package = Package::findOrFail($request->package_id);
@@ -116,13 +112,14 @@ class PackageController extends Controller
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) {
PackagePurchase::create([
'tenant_id' => $tenant->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,
'type' => 'endcustomer_event',
'purchased_at' => now(),
@@ -165,6 +162,7 @@ class PackageController extends Controller
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider' => 'free',
'provider_id' => 'free_wizard',
'price' => $package->price,
'type' => 'endcustomer_event',
@@ -186,156 +184,33 @@ class PackageController extends Controller
], 201);
}
public function createPayPalOrder(Request $request): JsonResponse
public function createPaddleCheckout(Request $request): JsonResponse
{
$request->validate([
'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');
if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
}
$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')
);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$client = PayPalClient::client($environment);
$request = new OrdersCreateRequest;
$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',
],
$payload = [
'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'),
];
try {
$response = $client->execute($request);
$order = $response->result;
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
return response()->json([
'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);
}
return response()->json($checkout);
}
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
@@ -345,6 +220,7 @@ class PackageController extends Controller
'tenant_id' => $tenant->id,
'event_id' => $request->event_id,
'package_id' => $package->id,
'provider' => 'free',
'provider_id' => 'free',
'price' => $package->price,
'type' => $request->type,
@@ -397,7 +273,4 @@ class PackageController extends Controller
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;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
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
{
@@ -38,7 +37,7 @@ class RegisteredUserController extends Controller
*/
public function store(Request $request)
{
$fullName = trim($request->first_name . ' ' . $request->last_name);
$fullName = trim($request->first_name.' '.$request->last_name);
$validated = $request->validate([
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
@@ -73,7 +72,7 @@ class RegisteredUserController extends Controller
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $fullName,
'slug' => Str::slug($fullName . '-' . now()->timestamp),
'slug' => Str::slug($fullName.'-'.now()->timestamp),
'email' => $request->email,
'is_active' => true,
'is_suspended' => false,
@@ -123,6 +122,7 @@ class RegisteredUserController extends Controller
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'price' => 0,
'purchased_at' => now(),
'provider' => 'free',
'provider_id' => 'free',
]);
@@ -146,8 +146,3 @@ class RegisteredUserController extends Controller
return Inertia::location(route('verification.notice'));
}
}

View File

@@ -7,22 +7,16 @@ use App\Models\AbandonedCheckout;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
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
{
@@ -32,6 +26,7 @@ class CheckoutController extends Controller
{
$googleStatus = session()->pull('checkout_google_status');
$googleError = session()->pull('checkout_google_error');
$googleProfile = session()->pull('checkout_google_profile');
$packageOptions = Package::orderBy('price')->get()
->map(fn (Package $pkg) => $this->presentPackage($pkg))
@@ -41,8 +36,6 @@ class CheckoutController extends Controller
return Inertia::render('marketing/CheckoutWizardPage', [
'package' => $this->presentPackage($package),
'packageOptions' => $packageOptions,
'stripePublishableKey' => config('services.stripe.key'),
'paypalClientId' => config('services.paypal.client_id'),
'privacyHtml' => view('legal.datenschutz-partial')->render(),
'auth' => [
'user' => Auth::user(),
@@ -50,6 +43,11 @@ class CheckoutController extends Controller
'googleAuth' => [
'status' => $googleStatus,
'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(), [
'email' => 'required|email|unique:users,email',
'username' => 'required|string|max:255|unique:users,username',
'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',
'terms' => 'required|accepted',
'privacy_consent' => 'required|accepted',
'locale' => 'nullable|string|max:10',
]);
if ($validator->fails()) {
@@ -76,39 +81,46 @@ class CheckoutController extends Controller
// User erstellen
$user = User::create([
'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),
'pending_purchase' => true,
]);
// Tenant erstellen
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $validated['first_name'] . ' ' . $validated['last_name'],
'slug' => Str::slug($validated['first_name'] . ' ' . $validated['last_name'] . '-' . now()->timestamp),
'email' => $validated['email'],
'is_active' => true,
'is_suspended' => false,
'event_credits_balance' => 0,
'subscription_tier' => 'free',
'subscription_expires_at' => null,
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'font_family' => 'Inter, sans-serif',
],
'features' => [
'photo_likes_enabled' => false,
'event_checklist' => false,
'custom_domain' => false,
'advanced_analytics' => false,
],
'custom_domain' => null,
'contact_email' => $validated['email'],
'event_default_type' => 'general',
]),
]);
'user_id' => $user->id,
'name' => $validated['first_name'].' '.$validated['last_name'],
'slug' => Str::slug($validated['first_name'].' '.$validated['last_name'].'-'.now()->timestamp),
'email' => $validated['email'],
'is_active' => true,
'is_suspended' => false,
'event_credits_balance' => 0,
'subscription_tier' => 'free',
'subscription_expires_at' => null,
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'font_family' => 'Inter, sans-serif',
],
'features' => [
'photo_likes_enabled' => false,
'event_checklist' => false,
'custom_domain' => false,
'advanced_analytics' => false,
],
'custom_domain' => null,
'contact_email' => $validated['email'],
'event_default_type' => 'general',
]),
]);
$user->forceFill(['tenant_id' => $tenant->id])->save();
// Package zuweisen
@@ -151,12 +163,12 @@ class CheckoutController extends Controller
// Custom Auth für Identifier (E-Mail oder Username)
$identifier = $request->identifier;
$user = User::where('email', $identifier)
->orWhere('username', $identifier)
->first();
->orWhere('username', $identifier)
->first();
if (!$user || !Hash::check($request->password, $user->password)) {
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json([
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']]
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']],
], 422);
}
@@ -165,7 +177,7 @@ class CheckoutController extends Controller
// Checkout-spezifische Logik
DB::transaction(function () use ($request, $user, $packageId) {
if ($packageId && !$user->pending_purchase) {
if ($packageId && ! $user->pending_purchase) {
$user->update(['pending_purchase' => true]);
$request->session()->put('pending_package_id', $packageId);
}
@@ -242,165 +254,6 @@ class CheckoutController extends Controller
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
{
if (isset($package->is_free)) {

View File

@@ -2,17 +2,13 @@
namespace App\Http\Controllers;
use App\Mail\Welcome;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -52,56 +48,56 @@ class CheckoutGoogleController extends Controller
} catch (\Throwable $e) {
Log::warning('Google checkout login failed', ['message' => $e->getMessage()]);
$this->flashError($request, __('checkout.google_error_fallback'));
return $this->redirectBackToWizard($packageId);
}
$email = $googleUser->getEmail();
if (! $email) {
$this->flashError($request, __('checkout.google_missing_email'));
return $this->redirectBackToWizard($packageId);
}
$user = DB::transaction(function () use ($googleUser, $email) {
$existing = User::where('email', $email)->first();
$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,
]));
if ($existing) {
$existing->forceFill([
'name' => $googleUser->getName() ?: $existing->name,
'pending_purchase' => true,
'email_verified_at' => $existing->email_verified_at ?? now(),
])->save();
$existing = User::where('email', $email)->first();
if (! $existing->tenant) {
$this->createTenantForUser($existing, $googleUser->getName(), $email);
}
return $existing->fresh();
}
$user = User::create([
'name' => $googleUser->getName(),
if (! $existing) {
$request->session()->put('checkout_google_profile', array_filter([
'email' => $email,
'password' => Hash::make(Str::random(32)),
'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([
'name' => $googleUser->getName() ?: $existing->name,
'pending_purchase' => true,
'email_verified_at' => now(),
]);
'email_verified_at' => $existing->email_verified_at ?? now(),
])->save();
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(),
]);
if (! $existing->tenant) {
$this->createTenantForUser($existing, $googleUser->getName(), $email);
}
return tap($user)->setRelation('tenant', $tenant);
return $existing->fresh();
});
if (! $user->tenant) {
@@ -111,7 +107,8 @@ class CheckoutGoogleController extends Controller
Auth::login($user, true);
$request->session()->regenerate();
$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) {
$this->ensurePackageAttached($user, (int) $packageId);
@@ -128,7 +125,7 @@ class CheckoutGoogleController extends Controller
$counter = 1;
while (Tenant::where('slug', $slug)->exists()) {
$slug = $slugBase . '-' . $counter;
$slug = $slugBase.'-'.$counter;
$counter++;
}

View File

@@ -3,42 +3,36 @@
namespace App\Http\Controllers;
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\CheckoutSession;
use App\Models\Package;
use App\Models\TenantPackage;
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\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
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\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
use App\Support\Concerns\PresentsPackages;
class MarketingController extends Controller
{
use PresentsPackages;
public function __construct()
{
Stripe::setApiKey(config('services.stripe.key'));
}
public function __construct(
private readonly CheckoutSessionService $checkoutSessions,
private readonly PaddleCheckoutService $paddleCheckout,
) {}
public function index()
{
@@ -69,7 +63,7 @@ class MarketingController extends Controller
'email' => $request->email,
'message' => $request->message,
], $locale),
function ($message) use ($request, $contactAddress, $locale) {
function ($message) use ($contactAddress, $locale) {
$message->to($contactAddress)
->subject(__('emails.contact.subject', [], $locale));
}
@@ -94,22 +88,22 @@ class MarketingController extends Controller
*/
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);
if (!Auth::check()) {
if (! Auth::check()) {
return redirect()->route('register', ['package_id' => $package->id])
->with('message', __('marketing.packages.register_required'));
}
$user = Auth::user();
if (!$user->email_verified_at) {
if (! $user->email_verified_at) {
return redirect()->route('verification.notice')
->with('message', __('auth.verification_required'));
}
$tenant = $user->tenant;
if (!$tenant) {
if (! $tenant) {
abort(500, 'Tenant not found');
}
@@ -130,6 +124,7 @@ class MarketingController extends Controller
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider' => 'free',
'provider_id' => 'free',
'price' => $package->price,
'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'));
}
if ($package->type === 'reseller') {
return $this->stripeSubscription($request, $packageId);
if (! $package->paddle_price_id) {
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') {
return $this->paypalCheckout($request, $packageId);
}
$session = $this->checkoutSessions->createOrResume($user, $package, [
'tenant' => $tenant,
]);
return $this->checkout($request, $packageId);
}
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
/**
* 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'),
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
'success_url' => route('marketing.success', ['packageId' => $package->id]),
'return_url' => route('packages', ['highlight' => $package->slug]),
'metadata' => [
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
'checkout_session_id' => $session->id,
],
]);
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;
/**
* PayPal checkout with v2 Orders API (one-time payment).
*/
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');
} 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');
if (! $redirectUrl) {
throw ValidationException::withMessages([
'paddle' => __('marketing.packages.paddle_checkout_failed'),
]);
}
return redirect()->away($redirectUrl);
}
/**
* 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)
{
$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) {
return redirect('/event-admin')->with('success', __('marketing.success.welcome'));
}
@@ -392,7 +190,7 @@ class MarketingController extends Controller
$locale = $request->get('locale', app()->getLocale());
Log::info('Blog Index Debug - Initial', [
'locale' => $locale,
'full_url' => $request->fullUrl()
'full_url' => $request->fullUrl(),
]);
$query = BlogPost::query()
@@ -424,6 +222,7 @@ class MarketingController extends Controller
$post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '';
$post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', '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')
return $post;
});
@@ -432,7 +231,7 @@ class MarketingController extends Controller
'count' => $posts->count(),
'total' => $posts->total(),
'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'));
@@ -456,12 +255,12 @@ class MarketingController extends Controller
// Transform to array with translated strings for the current locale
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new StrikethroughExtension());
$environment->addExtension(new TaskListExtension());
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension);
$environment->addExtension(new AutolinkExtension);
$environment->addExtension(new StrikethroughExtension);
$environment->addExtension(new TaskListExtension);
$converter = new MarkdownConverter($environment);
$contentHtml = (string) $converter->convert($markdown);
@@ -471,7 +270,7 @@ class MarketingController extends Controller
'type' => gettype($contentHtml),
'is_string' => is_string($contentHtml),
'length' => strlen($contentHtml ?? ''),
'preview' => substr((string)$contentHtml, 0, 200) . '...'
'preview' => substr((string) $contentHtml, 0, 200).'...',
]);
$post = [
@@ -484,7 +283,7 @@ class MarketingController extends Controller
'published_at' => $postModel->published_at->toDateString(),
'slug' => $postModel->slug,
'author' => $postModel->author ? [
'name' => $postModel->author->name
'name' => $postModel->author->name,
] : null,
];
@@ -527,11 +326,11 @@ class MarketingController extends Controller
'locale' => app()->getLocale(),
'url' => request()->fullUrl(),
'route' => request()->route()->getName(),
'isInertia' => request()->header('X-Inertia')
'isInertia' => request()->header('X-Inertia'),
]);
$validTypes = ['hochzeit', 'geburtstag', 'firmenevent'];
if (!in_array($type, $validTypes)) {
if (! in_array($type, $validTypes)) {
Log::warning('Invalid occasion type accessed', ['type' => $type]);
abort(404, 'Invalid occasion type');
}

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}'",
'https://js.stripe.com',
'https://js.stripe.network',
'https://cdn.paddle.com',
'https://global.localizecdn.com',
];
$styleSources = [
@@ -51,11 +53,22 @@ class ContentSecurityPolicy
"'self'",
'https://api.stripe.com',
'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 = [
"'self'",
'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 = [

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;
public const STATUS_DRAFT = 'draft';
public const STATUS_AWAITING_METHOD = 'awaiting_payment_method';
public const STATUS_REQUIRES_CUSTOMER_ACTION = 'requires_customer_action';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
public const PROVIDER_NONE = 'none';
public const PROVIDER_STRIPE = 'stripe';
public const PROVIDER_PAYPAL = 'paypal';
public const PROVIDER_PADDLE = 'paddle';
public const PROVIDER_FREE = 'free';
/**

View File

@@ -2,10 +2,10 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Package extends Model
{
@@ -29,6 +29,11 @@ class Package extends Model
'description',
'description_translations',
'description_table',
'paddle_product_id',
'paddle_price_id',
'paddle_sync_status',
'paddle_synced_at',
'paddle_snapshot',
];
protected $casts = [
@@ -45,9 +50,10 @@ class Package extends Model
'name_translations' => 'array',
'description_translations' => 'array',
'description_table' => 'array',
'paddle_synced_at' => 'datetime',
'paddle_snapshot' => 'array',
];
protected function features(): Attribute
{
return Attribute::make(
@@ -73,7 +79,6 @@ class Package extends Model
);
}
public function eventPackages(): HasMany
{
return $this->hasMany(EventPackage::class);
@@ -104,12 +109,12 @@ class Package extends Model
$locale = $locale ?: app()->getLocale();
$translations = $this->name_translations ?? [];
if (!empty($translations[$locale])) {
if (! empty($translations[$locale])) {
return $translations[$locale];
}
foreach (['en', 'de'] as $fallback) {
if ($locale !== $fallback && !empty($translations[$fallback])) {
if ($locale !== $fallback && ! empty($translations[$fallback])) {
return $translations[$fallback];
}
}

View File

@@ -16,6 +16,7 @@ class PackagePurchase extends Model
'tenant_id',
'event_id',
'package_id',
'provider',
'provider_id',
'price',
'type',
@@ -78,11 +79,11 @@ class PackagePurchase extends Model
parent::boot();
static::creating(function ($purchase) {
if (!$purchase->tenant_id) {
if (! $purchase->tenant_id) {
throw new \Exception('Tenant ID is required for package purchases.');
}
if (!$purchase->purchased_at) {
if (! $purchase->purchased_at) {
$purchase->purchased_at = now();
}
$purchase->refunded = false;

View File

@@ -5,7 +5,6 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class TenantPackage extends Model
{
@@ -16,6 +15,7 @@ class TenantPackage extends Model
protected $fillable = [
'tenant_id',
'package_id',
'paddle_subscription_id',
'price',
'purchased_at',
'expires_at',
@@ -43,30 +43,32 @@ class TenantPackage extends Model
public function isActive(): bool
{
return $this->active && (!$this->expires_at || $this->expires_at->isFuture());
return $this->active && (! $this->expires_at || $this->expires_at->isFuture());
}
public function canCreateEvent(): bool
{
if (!$this->isActive()) {
if (! $this->isActive()) {
return false;
}
if (!$this->package->isReseller()) {
if (! $this->package->isReseller()) {
return false;
}
$maxEvents = $this->package->max_events_per_year ?? 0;
return $this->used_events < $maxEvents;
}
public function getRemainingEventsAttribute(): int
{
if (!$this->package->isReseller()) {
if (! $this->package->isReseller()) {
return 0;
}
$max = $this->package->max_events_per_year ?? 0;
return max(0, $max - $this->used_events);
}
@@ -75,17 +77,21 @@ class TenantPackage extends Model
parent::boot();
static::creating(function ($tenantPackage) {
if (!$tenantPackage->purchased_at) {
if (! $tenantPackage->purchased_at) {
$tenantPackage->purchased_at = now();
}
if (!$tenantPackage->expires_at && $tenantPackage->package) {
if (! $tenantPackage->expires_at && $tenantPackage->package) {
$tenantPackage->expires_at = now()->addYear(); // Standard für Reseller
}
$tenantPackage->active = true;
});
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;
}
});

View File

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

View File

@@ -11,8 +11,7 @@ class CheckoutPaymentService
public function __construct(
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
) {
}
) {}
public function initialiseStripe(CheckoutSession $session, array $payload = []): array
{
@@ -40,32 +39,6 @@ class CheckoutPaymentService
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
{
if ($session->provider !== CheckoutSession::PROVIDER_FREE) {

View File

@@ -35,7 +35,7 @@ class CheckoutSessionService
return $existing;
}
$session = new CheckoutSession();
$session = new CheckoutSession;
$session->id = (string) Str::uuid();
$session->status = CheckoutSession::STATUS_DRAFT;
$session->provider = CheckoutSession::PROVIDER_NONE;
@@ -69,8 +69,8 @@ class CheckoutSessionService
$session->stripe_payment_intent_id = null;
$session->stripe_customer_id = null;
$session->stripe_subscription_id = null;
$session->paypal_order_id = null;
$session->paypal_subscription_id = null;
$session->paddle_checkout_id = null;
$session->paddle_transaction_id = null;
$session->provider_metadata = [];
$session->failure_reason = null;
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
@@ -85,7 +85,11 @@ class CheckoutSessionService
{
$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}]");
}
@@ -101,7 +105,7 @@ class CheckoutSessionService
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->failure_reason = $reason;

View File

@@ -3,17 +3,23 @@
namespace App\Services\Checkout;
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\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class CheckoutWebhookService
{
public function __construct(
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
) {
}
private readonly PaddleSubscriptionService $paddleSubscriptions,
) {}
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;
$resource = $event['resource'] ?? [];
$data = $event['data'] ?? [];
if (! $eventType || ! is_array($resource)) {
if (! $eventType || ! is_array($data)) {
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) {
Log::info('[CheckoutWebhook] Paddle session not resolved', [
'event_type' => $eventType,
'transaction_id' => $data['id'] ?? null,
]);
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);
if (! $lock->get()) {
Log::info('[CheckoutWebhook] PayPal lock busy', [
'order_id' => $orderId,
Log::info('[CheckoutWebhook] Paddle lock busy', [
'transaction_id' => $transactionId,
'session_id' => $session->id,
]);
@@ -102,22 +116,29 @@ class CheckoutWebhookService
}
try {
$session->forceFill([
'paypal_order_id' => $orderId ?: $session->paypal_order_id,
'provider' => CheckoutSession::PROVIDER_PAYPAL,
])->save();
if ($transactionId) {
$session->forceFill([
'paddle_transaction_id' => $transactionId,
'provider' => CheckoutSession::PROVIDER_PADDLE,
])->save();
} elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
$session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->save();
}
$metadata = [
'paypal_last_event' => $eventType,
'paypal_last_event_id' => $event['id'] ?? null,
'paypal_last_update_at' => now()->toIso8601String(),
'paypal_order_id' => $orderId,
'paypal_capture_id' => $resource['id'] ?? null,
'paddle_last_event' => $eventType,
'paddle_transaction_id' => $transactionId,
'paddle_status' => $data['status'] ?? null,
'paddle_last_update_at' => now()->toIso8601String(),
];
if (! empty($data['checkout_id'])) {
$metadata['paddle_checkout_id'] = $data['checkout_id'];
}
$this->mergeProviderMetadata($session, $metadata);
return $this->applyPayPalEvent($session, $eventType, $resource);
return $this->applyPaddleEvent($session, $eventType, $data);
} finally {
$lock->release();
}
@@ -131,16 +152,19 @@ class CheckoutWebhookService
$this->sessions->markProcessing($session, [
'stripe_intent_status' => $intent['status'] ?? null,
]);
return true;
case 'payment_intent.requires_action':
$reason = $intent['next_action']['type'] ?? 'requires_action';
$this->sessions->markRequiresCustomerAction($session, $reason);
return true;
case 'payment_intent.payment_failed':
$failure = $intent['last_payment_error']['message'] ?? 'payment_failed';
$this->sessions->markFailed($session, $failure);
return true;
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) {
case 'CHECKOUT.ORDER.APPROVED':
case 'transaction.created':
case 'transaction.processing':
$this->sessions->markProcessing($session, [
'paypal_order_status' => $resource['status'] ?? null,
'paddle_status' => $status ?: null,
]);
return true;
case 'PAYMENT.CAPTURE.COMPLETED':
case 'transaction.completed':
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
$this->sessions->markProcessing($session, [
'paypal_order_status' => $resource['status'] ?? null,
'paddle_status' => $status ?: 'completed',
]);
$this->assignment->finalise($session, [
'source' => 'paypal_webhook',
'paypal_order_id' => $resource['order_id'] ?? null,
'paypal_capture_id' => $resource['id'] ?? null,
'source' => 'paddle_webhook',
'provider' => CheckoutSession::PROVIDER_PADDLE,
'provider_reference' => $data['id'] ?? null,
'payload' => $data,
]);
$this->sessions->markCompleted($session, now());
@@ -191,8 +220,11 @@ class CheckoutWebhookService
return true;
case 'PAYMENT.CAPTURE.DENIED':
$this->sessions->markFailed($session, 'paypal_capture_denied');
case 'transaction.failed':
case 'transaction.cancelled':
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
$this->sessions->markFailed($session, $reason);
return true;
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
{
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
@@ -230,42 +425,45 @@ class CheckoutWebhookService
return null;
}
protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession
protected function locatePaddleSession(array $data): ?CheckoutSession
{
if ($orderId) {
$session = CheckoutSession::query()
->where('paypal_order_id', $orderId)
->first();
$metadata = $data['metadata'] ?? [];
if ($session) {
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()
->where('tenant_id', $tenantId)
->where('package_id', $packageId)
->whereNotIn('status', [CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_CANCELLED])
->latest()
->first();
if ($session) {
return $session;
}
}
}
$metadata = $this->extractPayPalMetadata($resource);
$sessionId = $metadata['checkout_session_id'] ?? null;
$checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id');
if ($sessionId) {
return CheckoutSession::find($sessionId);
if ($checkoutId) {
return CheckoutSession::query()
->where('provider_metadata->paddle_checkout_id', $checkoutId)
->first();
}
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
{
$charges = $intent['charges']['data'] ?? null;
@@ -276,4 +474,3 @@ class CheckoutWebhookService
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,
'type' => $package->type,
'price' => $package->price,
'paddle_product_id' => $package->paddle_product_id,
'paddle_price_id' => $package->paddle_price_id,
'description' => $description,
'description_breakdown' => $table,
'gallery_duration_label' => $galleryDuration,
@@ -111,20 +113,20 @@ trait PresentsPackages
{
$locale = app()->getLocale();
if (!$days || $days <= 0) {
if (! $days || $days <= 0) {
return $locale === 'en' ? 'permanent' : 'dauerhaft';
}
if ($days % 30 === 0) {
$months = (int) ($days / 30);
if ($locale === 'en') {
return $months === 1 ? '1 month' : $months . ' months';
return $months === 1 ? '1 month' : $months.' months';
}
return $months === 1 ? '1 Monat' : $months . ' Monate';
return $months === 1 ? '1 Monat' : $months.' Monate';
}
return $locale === 'en' ? $days . ' days' : $days . ' Tage';
return $locale === 'en' ? $days.' days' : $days.' Tage';
}
protected function normaliseFeatures(mixed $features): array
@@ -144,6 +146,7 @@ trait PresentsPackages
foreach ($features as $key => $value) {
if (is_string($value)) {
$list[] = $value;
continue;
}

View File

@@ -18,7 +18,6 @@
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9",
"league/commonmark": "^2.7",
"paypal/paypal-server-sdk": "^1.1",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-translatable": "^6.11",
"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",
"This file is @generated automatically"
],
"content-hash": "c4ce377acba80c944149cab30605d24c",
"content-hash": "5409eee4f26e2827449d85cf6b40209d",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -72,222 +72,6 @@
},
"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",
"version": "2.0.8",
@@ -4974,50 +4758,6 @@
},
"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",
"version": "0.5.6",
@@ -5108,62 +4848,6 @@
},
"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",
"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,10 +43,17 @@ return [
'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' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/') . '/checkout/auth/google/callback'),
'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/google/callback'),
],
'revenuecat' => [
@@ -63,11 +70,11 @@ return [
$redirects = [];
$devServer = env('VITE_DEV_SERVER_URL');
$redirects[] = rtrim($devServer ?: 'http://localhost:5173', '/') . '/event-admin/auth/callback';
$redirects[] = rtrim($devServer ?: 'http://localhost:5173', '/').'/event-admin/auth/callback';
$appUrl = env('APP_URL');
if ($appUrl) {
$redirects[] = rtrim($appUrl, '/') . '/event-admin/auth/callback';
$redirects[] = rtrim($appUrl, '/').'/event-admin/auth/callback';
} else {
$redirects[] = 'http://localhost:8000/event-admin/auth/callback';
}

View File

@@ -13,9 +13,10 @@ class PackageFactory extends Factory
public function definition(): array
{
$name = $this->faker->word();
return [
'name' => $name,
'slug' => Str::slug($name . '-' . uniqid()),
'slug' => Str::slug($name.'-'.uniqid()),
'description' => $this->faker->sentence(),
'price' => $this->faker->randomFloat(2, 0, 100),
'max_photos' => $this->faker->numberBetween(100, 1000),
@@ -29,6 +30,9 @@ class PackageFactory extends Factory
'advanced_analytics' => $this->faker->boolean(),
]),
'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 [
'tenant_id' => Tenant::factory(),
'package_id' => Package::factory(),
'provider' => 'manual',
'provider_id' => $this->faker->uuid(),
'price' => $this->faker->randomFloat(2, 0, 500),
'purchased_at' => now(),

View File

@@ -34,8 +34,8 @@ return new class extends Migration
$table->string('stripe_payment_intent_id')->nullable();
$table->string('stripe_customer_id')->nullable();
$table->string('stripe_subscription_id')->nullable();
$table->string('paypal_order_id')->nullable();
$table->string('paypal_subscription_id')->nullable();
$table->string('paddle_checkout_id')->nullable();
$table->string('paddle_transaction_id')->nullable();
$table->json('provider_metadata')->nullable();
$table->string('locale', 5)->nullable();
@@ -47,7 +47,8 @@ return new class extends Migration
$table->softDeletes();
$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('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([
'tenant_id' => $tenant->id,
'package_id' => $premium->id,
'provider' => 'stripe',
'provider_id' => 'stripe_demo_pi',
'price' => $premium->price,
'type' => 'endcustomer_event',
@@ -188,6 +189,7 @@ class DemoLifecycleSeeder extends Seeder
'tenant_id' => $tenant->id,
'event_id' => $draftEvent->id,
'package_id' => $standard->id,
'provider' => 'paypal',
'provider_id' => 'paypal_demo_capture',
'price' => $standard->price,
'type' => 'endcustomer_event',
@@ -223,6 +225,7 @@ class DemoLifecycleSeeder extends Seeder
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $reseller->id,
'provider' => 'stripe',
'provider_id' => 'stripe_demo_subscription',
'price' => $reseller->price,
'type' => 'reseller_subscription',
@@ -253,6 +256,7 @@ class DemoLifecycleSeeder extends Seeder
'tenant_id' => $tenant->id,
'event_id' => $event->id,
'package_id' => $standard->id,
'provider' => 'manual',
'provider_id' => 'reseller_allowance',
'price' => 0,
'type' => 'endcustomer_event',

View File

@@ -10,7 +10,7 @@
### 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).
- **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).
### Frontend (Register.tsx)
@@ -30,7 +30,7 @@
- **Linter/TS**: Keine Errors; Intelephense fixed durch JsonResponse use und as string cast.
## 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
- Inertia-Forms: Bei preserveState JSON-Response für custom Redirects verwenden, statt location() (vermeidet State-Ignorieren).

View File

@@ -8,7 +8,7 @@
### 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] 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`.)*
### 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.)*
### 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.
## 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] 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 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] Add webhook handling matrix for Stripe invoice/payment events and PayPal 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] 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 Paddle subscription lifecycle.
- [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
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
- **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).
- **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).
@@ -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.
## 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.
- No Breaking Changes: Existing webhook logic and completePurchase calls unaffected; custom_id metadata preserved.
## 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.
### 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`.
### Event Creation Wizard
@@ -57,7 +57,7 @@
**Porting Recommendation**
- 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.
- 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
- **`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.
## 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.
- 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.
## 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
- 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
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:
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.
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").
pakettyp "reseller":
PayPal:
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).
Paddle:
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:
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.

View File

@@ -57,9 +57,9 @@ Der Anbieter verwendet Inhalte ausschließlich zur technischen Bereitstellung (S
## 7. Preise und Zahlung
1. Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung.
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:
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
5. Der Anbieter erhält von diesen Diensten nur Zahlungs- und Statusinformationen zur Abwicklung.
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
1. Prices valid at the time of booking apply.
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:
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
5. The Provider only receives transaction and payment status data necessary for processing.
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
- 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
- Technische Daten: IP-Adresse, Browsertyp, Zeitstempel, Geräteinformationen
- 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 |
| 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 |
| 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 |
@@ -48,13 +48,13 @@ Die Verarbeitung erfolgt ausschließlich innerhalb der EU.
---
## 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.
Wir speichern keine Zahlungs- oder Kreditkartendaten.
Rechtsgrundlage: Art. 6 Abs. 1 lit. b und lit. c DSGVO.
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
---
@@ -88,7 +88,7 @@ Eine Einwilligung ist nicht erforderlich.
## 10. Weitergabe an Dritte
Eine Weitergabe erfolgt nur an:
- Zahlungsdienstleister (PayPal, Stripe)
- Zahlungsdienstleister (Paddle, Stripe)
- Hoster (Hetzner)
- 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
- 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
- Technical data: IP address, browser type, timestamp, device information
- 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 |
| 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 |
| 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 |
@@ -48,12 +48,12 @@ All processing takes place within the EU.
---
## 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.
Legal basis: Art. 6(1)(b) and (c) GDPR.
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
---
@@ -87,7 +87,7 @@ No consent is required.
## 10. Data Disclosure
Data is only shared with:
- Payment providers (PayPal, Stripe)
- Payment providers (Paddle, Stripe)
- Hosting provider (Hetzner)
- 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`.
- **Frontend (Admin PWA):** Dashboard-Cards für Balance, Kauf-Integration (RevenueCat).
- **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.
- **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.
@@ -99,7 +99,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
$table->foreignId('tenant_id')->nullable()->constrained();
$table->foreignId('event_id')->nullable()->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->enum('type', ['endcustomer_event', 'reseller_subscription']);
$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):**
- 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).
- Bulk-Actions: Renew Selected.
- **PurchaseResource (SuperAdmin/TenantAdmin):**
- 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.
- Widgets: StatsOverview (Total Revenue, Monthly Purchases, Top Package), ChartWidget (Revenue over Time via Laravel Charts).
- 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)
- **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).
- **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.
- **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.
- **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."
- **impressum.blade.php:** "Monetarisierung: Packages via Stripe/PayPal; USt-ID: ...; Support: support@fotospiel.de".
- **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 Paddle; USt-ID: ...; Support: support@fotospiel.de".
- **Allgemein:** Datum "Aktualisiert: 2025-09-26 Package-Modell"; Links zu Provider-Datenschutz.
**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)
- **Controllers:**
- `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).
- **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):**
@@ -175,7 +175,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
## 7. Frontend-Anpassungen (Todo 8/9)
- **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).
- SettingsPage.tsx: Reseller-Übersicht (used_events/Progress, Renew-Button).
- 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.
- 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)
- **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + PayPal (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).
- **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PayPalWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase).
- **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' → Paddle::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail).
- **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.
- **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.
## 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).
- **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).
- **Anpassungen:** RevenueCatWebhookTest → Stripe/PayPalWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422).
- **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 Paddle, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max).
- **Anpassungen:** RevenueCatWebhookTest → PaddleWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422).
- **Coverage:** 80% für Billing/DB; Mock Providers für Isolation.
## 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.
- **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).
- **Dependencies:** Stripe/PayPal SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe).
- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Stripe/PayPal Test-Accounts.
- **Dependencies:** Paddle SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe).
- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Paddle Test-Accounts.
## 12. Todo-List (Status: Alle Planung completed)
- [x] Analyse.

View File

@@ -1,7 +1,7 @@
# Billing and Payments
## 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
- **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` |
| `customer.subscription.deleted` | Finalises cancellation/downgrade | `handleSubscriptionDeleted` |
## PayPal Integration
- **SDK**: Migrated to PayPal 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`.
- **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.
## Paddle Integration
- **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. `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). `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.
- **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**:
| 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` |
- **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`.
- **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
- **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.
- **Constraints**: `type` CHECK (endcustomer_event, reseller_subscription), `price` NOT NULL.
## 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'.
3. **Cancellation/Refund**: Webhook updates status to 'cancelled', deactivates `TenantPackage`.
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
- GDPR: No PII in logs/metadata beyond necessary (tenant_id anonymous).
- 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.

View File

@@ -2,16 +2,16 @@
## Goals
- 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).
- Prepare the frontend wizard to drive the flow as an SPA without relying on server-side redirects.
## 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.
- **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.
- **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.
- **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` |
| `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` |
| `completed` | Checkout finished, package assigned, confirmation step unblocked. | none |
| `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'])`:
- `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`.
- `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/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/capture` (`capturePayPalOrder`): capture order server-side, transition to `processing` if status `COMPLETED`.
- `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` (`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}/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).
- `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
- `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`).
- `SyncCheckoutFromWebhook` job: invoked by webhook controllers with provider payload, looks up `CheckoutSession` via provider id, runs assignment if needed, records failure states.
### 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 `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`.
### Validation & Security
- 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`).
- 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
@@ -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.
- 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.
- 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.
- 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`.
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`.
2. `createPayPalOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type.
3. After approval, `capturePayPalOrder` verifies capture status; on `COMPLETED`, transitions to `processing`.
2. `createPaddleOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type.
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.
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
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.
3. **Phase 3**: enable feature flag for production tenants, monitor Stripe/PayPal events, then delete legacy marketing payment paths and routes.
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 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.
## Testing & QA
- **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.
- **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, PayPal approval, failure retry, free package shortcut, session resume after refresh.
- **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, 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.
- **Scheduler**: test `checkout:expire-sessions` to confirm PaymentIntents are cancelled and sessions flagged `cancelled`.
## 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.
- 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)
- **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).
- **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`.

View File

@@ -11,7 +11,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
- **Galerie-Management**: Upload, Moderation, Feature-Flags, Analytics.
- **Mitglieder-Verwaltung**: Einladungen, Rollen, Zugriffskontrolle.
- **Tasks & Emotions**: Bibliothek, Zuweisung, Fortschritts-Tracking.
- **Abrechnung**: Paketübersicht, Stripe/PayPal Checkout, Ledger.
- **Abrechnung**: Paketübersicht, Paddle Checkout, Ledger.
- **Einstellungen**: Branding, Limits, Rechtstexte, Benachrichtigungen.
- **Offline-Support**: App-Shell-Caching, Queueing von Mutationen, Sync bei Reconnect.
- **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.
- 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`).
- 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.
### Event Lifecycle
@@ -45,7 +45,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
### Billing & Checkout
- 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).
### Settings
@@ -83,7 +83,7 @@ Die App nutzt Endpunkte aus `docs/prp/03-api.md`.
## Teststrategie
- **PHPUnit**: Feature-Tests für Auth-Guards (Tenant ohne Events → Welcome Flow).
- **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.
- **Smoke Tests**: `npm run test:e2e` in CI mit optionalen Credentials (`E2E_TENANT_EMAIL`, `E2E_TENANT_PASSWORD`, Stripe/PayPal Keys).
- **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`, 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.

View File

@@ -18,8 +18,8 @@
| --- | --- | --- | --- |
| 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) |
| Paketwahl | `/event-admin/welcome/packages` | `PackageCard`, `PricingToggle`, `QueryPackageList` | Stripe/PayPal 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“ |
| 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, 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“ |
### Guards & Fortschritt
@@ -39,7 +39,7 @@
- **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.
- **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)
```
@@ -61,7 +61,7 @@
## Testabdeckung (UI)
- **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)
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>
</g>
<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>
</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
</text>
<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>
<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">
@@ -44,10 +44,10 @@
<g transform="translate(660,380)">
<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">
PayPal Smart Buttons
Paddle Checkout Links
</text>
<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>
</g>
</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“. |
| `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. |
| `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).
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.
- Pipe failed capture events into credit ledger audits and operator alerts.
- **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] 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] 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.
## 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.
- 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",
"vat_id": "Umsatzsteuer-ID: DE123456789",
"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",
"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.",
"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.",
"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.",
"rights": "Ihre Rechte: Auskunft, Löschung, Widerspruch.",
"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).",
"and": "und",
"stripe_privacy": "Stripe Datenschutz",
"paypal_privacy": "PayPal Datenschutz",
"agb": "Allgemeine Geschäftsbedingungen",
"effective_from": "Gültig seit {{date}}",
"version": "Version {{version}}"

View File

@@ -88,7 +88,7 @@
"faq_q3": "Was passiert bei Ablauf?",
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
"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?",
"contact_us": "Kontaktieren Sie uns",
"feature_live_slideshow": "Live-Slideshow",
@@ -115,7 +115,7 @@
"billing_per_year": "pro Jahr",
"more_features": "+{{count}} weitere Features",
"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",
"feature_highlights": "Feature-Highlights",
"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_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_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": {
"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!",
@@ -163,7 +163,9 @@
"euro": "€"
},
"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": {
"title": "Fotospiel - Blog",
@@ -280,22 +282,15 @@
"no_account": "Kein Konto? Registrieren",
"manage_subscription": "Abo verwalten",
"stripe_dashboard": "Stripe-Dashboard",
"paypal_dashboard": "PayPal-Dashboard",
"trial_activated": "Trial aktiviert für 14 Tage!"
},
"payment": {
"title": "Zahlung",
"card_details": "Kartendetails",
"stripe": "Kreditkarte",
"paypal": "PayPal",
"submit_stripe": "Bezahlen mit Karte (:price)",
"submit_paypal": "Bezahlen mit PayPal (:price)",
"loading_stripe": "Lade Stripe...",
"paypal_description": "Sichere Zahlung mit PayPal",
"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",
"confirm_error": "Bestätigung fehlgeschlagen",
"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.",
"activate_package": "Paket aktivieren",
"loading_payment": "Zahlungsdaten werden geladen...",
"secure_payment_desc": "Sichere Zahlung mit Kreditkarte, Debitkarte oder SEPA-Lastschrift.",
"secure_paypal_desc": "Sichere Zahlung mit PayPal.",
"secure_payment_desc": "Sichere Zahlung über Paddle.",
"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. ",
"error_card": "Kartenfehler aufgetreten.",
"error_validation": "Eingabedaten sind ungültig.",
@@ -419,25 +425,18 @@
"unexpected_status": "Unerwarteter Zahlungsstatus: {status}",
"processing_btn": "Verarbeitung...",
"pay_now": "Jetzt bezahlen (€{price})",
"stripe_not_loaded": "Stripe ist nicht initialisiert. Bitte Seite neu laden.",
"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.",
"status_loading": "Zahlungsvorbereitung läuft…",
"status_ready": "Zahlungsformular bereit. Bitte gib deine Daten ein.",
"status_processing": "Zahlung mit {{provider}} wird verarbeitet…",
"status_success": "Zahlung bestätigt. Wir schließen den Kauf ab…",
"status_info_title": "Zahlungsstatus",
"status_processing_title": "Checkout wird geöffnet",
"status_ready_title": "Checkout geöffnet",
"status_error_title": "Zahlung fehlgeschlagen",
"status_success_title": "Zahlung abgeschlossen",
"status_retry": "Erneut versuchen",
"method_stripe": "Kreditkarte (Stripe)",
"method_paypal": "PayPal"
"status_retry": "Erneut versuchen"
},
"confirmation_step": {
"title": "Bestätigung",

View File

@@ -10,14 +10,14 @@
"contact": "Contact",
"vat_id": "VAT ID: DE123456789",
"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",
"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.",
"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.",
"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.",
"rights": "Your rights: Information, deletion, objection. Contact us under Contact.",
"cookies": "Cookies: Only functional cookies for the PWA.",

View File

@@ -78,7 +78,7 @@
"faq_q3": "What happens when it expires?",
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.",
"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?",
"contact_us": "Contact Us",
"feature_live_slideshow": "Live Slideshow",
@@ -105,7 +105,7 @@
"billing_per_year": "per year",
"more_features": "+{{count}} more features",
"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",
"feature_highlights": "Feature Highlights",
"more_details_tab": "More Details",
@@ -149,7 +149,9 @@
},
"currency": {
"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": {
"title": "Fotospiel - Blog",
@@ -266,22 +268,15 @@
"no_account": "No Account? Register",
"manage_subscription": "Manage Subscription",
"stripe_dashboard": "Stripe Dashboard",
"paypal_dashboard": "PayPal Dashboard",
"trial_activated": "Trial activated for 14 days!"
},
"payment": {
"title": "Payment",
"card_details": "Card Details",
"stripe": "Credit Card",
"paypal": "PayPal",
"submit_stripe": "Pay with Card (:price)",
"submit_paypal": "Pay with PayPal (:price)",
"loading_stripe": "Loading Stripe...",
"paypal_description": "Secure payment with PayPal",
"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",
"confirm_error": "Confirmation failed",
"complete_error": "Payment could not be completed"
@@ -398,8 +393,19 @@
"free_package_desc": "This package is free. We activate it directly after confirmation.",
"activate_package": "Activate Package",
"loading_payment": "Payment data is loading...",
"secure_payment_desc": "Secure payment with credit card, debit card or SEPA direct debit.",
"secure_paypal_desc": "Pay securely with PayPal.",
"secure_payment_desc": "Secure payment with Paddle.",
"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. ",
"error_card": "Card error occurred.",
"error_validation": "Input data is invalid.",
@@ -413,25 +419,18 @@
"unexpected_status": "Unexpected payment status: {status}",
"processing_btn": "Processing...",
"pay_now": "Pay Now (${price})",
"stripe_not_loaded": "Stripe is not initialized. Please reload the page.",
"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.",
"status_loading": "Preparing secure payment data…",
"status_ready": "Payment form ready. Enter your details to continue.",
"status_processing": "Processing payment with {{provider}}…",
"status_success": "Payment confirmed. Finalising your order…",
"status_info_title": "Payment status",
"status_processing_title": "We are opening the checkout",
"status_ready_title": "Checkout opened",
"status_error_title": "Payment failed",
"status_success_title": "Payment completed",
"status_retry": "Retry",
"method_stripe": "Credit Card (Stripe)",
"method_paypal": "PayPal"
"status_retry": "Retry"
},
"confirmation_step": {
"title": "Confirmation",

View File

@@ -119,6 +119,20 @@ export type CreditBalance = {
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 = {
id: 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 {
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
@@ -813,6 +846,35 @@ export async function getTenantPackagesOverview(): Promise<{
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> {
const response = await authorizedFetch('/api/v1/tenant/credits/balance');
if (response.status === 404) {
@@ -868,17 +930,17 @@ export async function createTenantPackagePaymentIntent(packageId: number): Promi
export async function completeTenantPackagePurchase(params: {
packageId: number;
paymentMethodId?: string;
paypalOrderId?: string;
paddleTransactionId?: string;
}): Promise<void> {
const { packageId, paymentMethodId, paypalOrderId } = params;
const { packageId, paymentMethodId, paddleTransactionId } = params;
const payload: Record<string, unknown> = { package_id: packageId };
if (paymentMethodId) {
payload.payment_method_id = paymentMethodId;
}
if (paypalOrderId) {
payload.paypal_order_id = paypalOrderId;
if (paddleTransactionId) {
payload.paddle_transaction_id = paddleTransactionId;
}
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');
}
export async function createTenantPayPalOrder(packageId: number): Promise<string> {
const response = await authorizedFetch('/api/v1/tenant/packages/paypal-create', {
export async function createTenantPaddleCheckout(packageId: number): Promise<{ checkout_url: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -913,24 +975,12 @@ export async function createTenantPayPalOrder(packageId: number): Promise<string
body: JSON.stringify({ package_id: packageId }),
});
const data = await jsonOrThrow<{ orderID: string }>(response, 'Failed to create PayPal order');
if (!data.orderID) {
throw new Error('Missing PayPal order ID');
const data = await jsonOrThrow<{ checkout_url: string }>(response, 'Failed to create Paddle checkout');
if (!data.checkout_url) {
throw new Error('Missing Paddle checkout URL');
}
return data.orderID;
}
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');
return { checkout_url: data.checkout_url };
}
export async function recordCreditPurchase(payload: {

View File

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

View File

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

View File

@@ -45,6 +45,27 @@
"available": "Verfügbar",
"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": {
@@ -149,8 +170,7 @@
"high": "Hoch",
"urgent": "Dringend"
}
}
,
},
"collections": {
"title": "Aufgabenvorlagen",
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",
@@ -244,58 +264,56 @@
"cancel": "Abbrechen",
"submit": "Emotion speichern"
}
}
,
"management": {
"billing": {
"title": "Pakete & Abrechnung",
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
"actions": {
"refresh": "Aktualisieren",
"exportCsv": "Export als CSV"
},
"errors": {
"load": "Paketdaten konnten nicht geladen werden.",
"more": "Weitere Einträge konnten nicht geladen werden."
},
"sections": {
"overview": {
"title": "Paketübersicht",
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
"empty": "Noch kein Paket aktiv.",
"emptyBadge": "Kein aktives Paket",
"cards": {
"package": {
"label": "Aktives Paket",
"helper": "Aktuell zugewiesen"
},
"used": {
"label": "Genutzte Events",
"helper": "Verfügbar: {{count}}"
},
"price": {
"label": "Preis (netto)"
},
"expires": {
"label": "Läuft ab",
"helper": "Automatische Verlängerung, falls aktiv"
}
},
"management": {
"billing": {
"title": "Pakete & Abrechnung",
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
"actions": {
"refresh": "Aktualisieren",
"exportCsv": "Export als CSV"
},
"errors": {
"load": "Paketdaten konnten nicht geladen werden.",
"more": "Weitere Einträge konnten nicht geladen werden."
},
"sections": {
"overview": {
"title": "Paketübersicht",
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
"empty": "Noch kein Paket aktiv.",
"emptyBadge": "Kein aktives Paket",
"cards": {
"package": {
"label": "Aktives Paket",
"helper": "Aktuell zugewiesen"
},
"used": {
"label": "Genutzte Events",
"helper": "Verfügbar: {{count}}"
},
"price": {
"label": "Preis (netto)"
},
"expires": {
"label": "Läuft ab",
"helper": "Automatische Verlängerung, falls aktiv"
}
}
},
"packages": {
"title": "Paket-Historie",
"description": "Übersicht über aktuelle und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.",
"card": {
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"used": "Genutzte Events",
"available": "Verfügbar",
"expires": "Läuft ab"
}
}
},
"packages": {
"title": "Paket-Historie",
"description": "Übersicht über aktuelle und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.",
"card": {
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"used": "Genutzte Events",
"available": "Verfügbar",
"expires": "Läuft ab"
}
}
}
}
}

View File

@@ -165,46 +165,25 @@
"failureTitle": "Aktivierung fehlgeschlagen",
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
},
"stripe": {
"sectionTitle": "Kartenzahlung (Stripe)",
"heading": "Kartenzahlung",
"notReady": "Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.",
"genericError": "Zahlung fehlgeschlagen. Bitte erneut versuchen.",
"missingPaymentId": "Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).",
"completionFailed": "Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.",
"errorTitle": "Zahlung fehlgeschlagen",
"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."
"paddle": {
"sectionTitle": "Paddle",
"heading": "Checkout mit Paddle",
"genericError": "Der Paddle-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.",
"errorTitle": "Paddle-Fehler",
"processing": "Paddle-Checkout wird geöffnet …",
"cta": "Paddle-Checkout öffnen",
"hint": "Es öffnet sich ein neuer Tab über Paddle (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück."
},
"nextStepsTitle": "Nächste Schritte",
"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.",
"Vor dem Go-Live Credits prüfen und Gäste-Link teilen."
],
"cta": {
"billing": {
"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"
},
"setup": {
@@ -262,7 +241,3 @@
}
}
}

View File

@@ -45,6 +45,27 @@
"available": "Remaining",
"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": {
@@ -149,8 +170,7 @@
"high": "High",
"urgent": "Urgent"
}
}
,
},
"collections": {
"title": "Task collections",
"subtitle": "Browse curated task bundles or activate them for your events.",
@@ -244,57 +264,56 @@
"cancel": "Cancel",
"submit": "Save emotion"
}
}
,
"management": {
"billing": {
"title": "Packages & billing",
"subtitle": "Manage your purchased packages and track their durations.",
"actions": {
"refresh": "Refresh",
"exportCsv": "Export CSV"
},
"errors": {
"load": "Unable to load package data.",
"more": "Unable to load more entries."
},
"sections": {
"overview": {
"title": "Package overview",
"description": "Your active package and the most important metrics.",
"empty": "No active package yet.",
"emptyBadge": "No active package",
"cards": {
"package": {
"label": "Active package",
"helper": "Currently assigned"
},
"used": {
"label": "Events used",
"helper": "Remaining: {{count}}"
},
"price": {
"label": "Price (net)"
},
"expires": {
"label": "Expires",
"helper": "Auto-renews if enabled"
}
},
"management": {
"billing": {
"title": "Packages & billing",
"subtitle": "Manage your purchased packages and track their durations.",
"actions": {
"refresh": "Refresh",
"exportCsv": "Export CSV"
},
"errors": {
"load": "Unable to load package data.",
"more": "Unable to load more entries."
},
"sections": {
"overview": {
"title": "Package overview",
"description": "Your active package and the most important metrics.",
"empty": "No active package yet.",
"emptyBadge": "No active package",
"cards": {
"package": {
"label": "Active package",
"helper": "Currently assigned"
},
"used": {
"label": "Events used",
"helper": "Remaining: {{count}}"
},
"price": {
"label": "Price (net)"
},
"expires": {
"label": "Expires",
"helper": "Auto-renews if enabled"
}
}
},
"packages": {
"title": "Package history",
"description": "Overview of current and past packages.",
"empty": "No packages purchased yet.",
"card": {
"statusActive": "Active",
"statusInactive": "Inactive",
"used": "Used events",
"available": "Available",
"expires": "Expires"
}
}
},
"packages": {
"title": "Package history",
"description": "Overview of current and past packages.",
"empty": "No packages purchased yet.",
"card": {
"statusActive": "Active",
"statusInactive": "Inactive",
"used": "Used events",
"available": "Available",
"expires": "Expires"
}
}
}
}
}

View File

@@ -165,46 +165,25 @@
"failureTitle": "Activation failed",
"errorMessage": "The free package could not be activated."
},
"stripe": {
"sectionTitle": "Card payment (Stripe)",
"heading": "Card payment",
"notReady": "Payment module not ready yet. Please refresh.",
"genericError": "Payment failed. Please try again.",
"missingPaymentId": "Could not confirm payment (missing payment ID).",
"completionFailed": "Purchase not recorded yet. Contact support with your payment confirmation.",
"errorTitle": "Payment failed",
"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."
"paddle": {
"sectionTitle": "Paddle",
"heading": "Checkout with Paddle",
"genericError": "The Paddle checkout could not be opened. Please try again.",
"errorTitle": "Paddle error",
"processing": "Opening the Paddle checkout …",
"cta": "Open Paddle checkout",
"hint": "A new tab opens via Paddle (merchant of record). Complete the payment there, then return to continue."
},
"nextStepsTitle": "Next steps",
"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.",
"Check credits before go-live and share your guest link."
],
"cta": {
"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"
},
"setup": {

View File

@@ -1,176 +1,65 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
StripeCheckoutForm,
PayPalCheckout,
} from '../pages/WelcomeOrderSummaryPage';
import { act, render, screen, waitFor } from '@testing-library/react';
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
const stripeRef: { current: any } = { current: null };
const elementsRef: { current: any } = { current: null };
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>;
},
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
createPaddleCheckoutMock: vi.fn(),
}));
vi.mock('../../api', () => ({
completeTenantPackagePurchase: completePurchaseMock,
createTenantPackagePaymentIntent: vi.fn(),
assignFreeTenantPackage: vi.fn(),
createTenantPayPalOrder: createPayPalOrderMock,
captureTenantPayPalOrder: capturePayPalOrderMock,
createTenantPaddleCheckout: createPaddleCheckoutMock,
}));
describe('StripeCheckoutForm', () => {
describe('PaddleCheckout', () => {
beforeEach(() => {
confirmPaymentMock.mockReset();
completePurchaseMock.mockReset();
stripeRef.current = { confirmPayment: confirmPaymentMock };
elementsRef.current = {};
createPaddleCheckoutMock.mockReset();
});
const renderStripeForm = (overrides?: Partial<React.ComponentProps<typeof StripeCheckoutForm>>) =>
render(
<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);
it('opens Paddle checkout when created successfully', async () => {
createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
const onSuccess = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
render(
<PayPalCheckout
<PaddleCheckout
packageId={99}
onSuccess={onSuccess}
t={(key: string) => key}
/>
);
expect(paypalPropsRef.current).toBeTruthy();
const { createOrder, onApprove } = paypalPropsRef.current;
await act(async () => {
const orderId = await createOrder();
expect(orderId).toBe('ORDER-123');
});
await act(async () => {
await onApprove({ orderID: 'ORDER-123' });
screen.getByRole('button').click();
});
await waitFor(() => {
expect(createPayPalOrderMock).toHaveBeenCalledWith(99);
expect(capturePayPalOrderMock).toHaveBeenCalledWith('ORDER-123');
expect(createPaddleCheckoutMock).toHaveBeenCalledWith(99);
expect(openSpy).toHaveBeenCalledWith('https://paddle.example/checkout', '_blank', 'noopener');
expect(onSuccess).toHaveBeenCalled();
});
openSpy.mockRestore();
});
it('surfaces missing order id errors', async () => {
createPayPalOrderMock.mockResolvedValue('ORDER-123');
it('shows an error message on failure', async () => {
createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
render(
<PayPalCheckout
<PaddleCheckout
packageId={99}
onSuccess={vi.fn()}
t={(key: string) => key}
/>
);
const { onApprove } = paypalPropsRef.current;
await act(async () => {
await onApprove({ orderID: undefined });
screen.getByRole('button').click();
});
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,
Loader2,
} from "lucide-react";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
import {
TenantWelcomeLayout,
@@ -26,30 +24,15 @@ import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PA
import { useTenantPackages } from "../hooks/useTenantPackages";
import {
assignFreeTenantPackage,
completeTenantPackagePurchase,
createTenantPackagePaymentIntent,
createTenantPayPalOrder,
captureTenantPayPalOrder,
createTenantPaddleCheckout,
} from "../../api";
import { getStripe } from '@/utils/stripe';
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
type StripeCheckoutProps = {
clientSecret: string;
type PaddleCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
};
type PayPalCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
currency?: string;
};
function useLocaleFormats(locale: string) {
const currencyFormatter = React.useMemo(
() =>
@@ -86,175 +69,53 @@ function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string)
.join(" ");
}
function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = React.useState(false);
function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
const [error, setError] = React.useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => {
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;
}
const handleCheckout = React.useCallback(async () => {
try {
await completeTenantPackagePurchase({
packageId,
paymentMethodId,
});
setStatus('processing');
setError(null);
const { checkout_url } = await createTenantPaddleCheckout(packageId);
window.open(checkout_url, '_blank', 'noopener');
setStatus('success');
onSuccess();
} catch (purchaseError) {
console.error("[Onboarding] Purchase completion failed", purchaseError);
setError(
purchaseError instanceof Error
? purchaseError.message
: t("summary.stripe.completionFailed")
);
setSubmitting(false);
} catch (err) {
console.error('[Onboarding] Paddle checkout failed', err);
setStatus('error');
setError(err instanceof Error ? err.message : t('summary.paddle.genericError'));
}
};
}, [packageId, onSuccess, t]);
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">
<p className="text-sm font-medium text-brand-slate">{t("summary.stripe.heading")}</p>
<PaymentElement id="payment-element" />
</div>
<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.paddle.heading')}</p>
{error && (
<Alert variant="destructive">
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle>
<AlertTitle>{t('summary.paddle.errorTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
disabled={submitting || !stripe || !elements}
size="lg"
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" />
{t("summary.stripe.submitting")}
{t('summary.paddle.processing')}
</>
) : (
<>
<CreditCard className="mr-2 size-4" />
{t("summary.stripe.submit")}
{t('summary.paddle.cta')}
</>
)}
</Button>
<p className="text-xs text-brand-navy/70">{t("summary.stripe.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>
<p className="text-xs text-brand-navy/70">{t('summary.paddle.hint')}</p>
</div>
);
}
@@ -267,7 +128,6 @@ export default function WelcomeOrderSummaryPage() {
const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
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 selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
@@ -295,48 +155,9 @@ export default function WelcomeOrderSummaryPage() {
const isSubscription = Boolean(packageDetails?.features?.subscription);
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 [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 =
progress.selectedPackage?.priceText ??
(packageDetails && typeof packageDetails.price === "number"
@@ -534,63 +355,16 @@ export default function WelcomeOrderSummaryPage() {
)}
{requiresPayment && (
<div className="space-y-6">
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.stripe.sectionTitle")}</h4>
{intentStatus === "loading" && (
<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}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
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 className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t('summary.paddle.sectionTitle')}</h4>
<PaddleCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
</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 { AdminLayout } from '../components/AdminLayout';
import { getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
import { isAuthError } from '../auth/tokens';
export default function BillingPage() {
@@ -21,6 +21,10 @@ export default function BillingPage() {
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
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 [error, setError] = React.useState<string | null>(null);
@@ -57,9 +61,18 @@ export default function BillingPage() {
setLoading(true);
setError(null);
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);
setActivePackage(packagesResult.activePackage);
setTransactions(paddleTransactions.data);
setTransactionCursor(paddleTransactions.nextCursor);
setTransactionsHasMore(paddleTransactions.hasMore);
} catch (err) {
if (!isAuthError(err)) {
setError(t('billing.errors.load'));
@@ -69,6 +82,25 @@ export default function BillingPage() {
}
}, [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(() => {
void loadAll();
}, [loadAll]);
@@ -176,11 +208,134 @@ export default function BillingPage() {
</CardContent>
</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>
);
}
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({
label,
value,

View File

@@ -15,10 +15,10 @@ const Footer: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<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>
<Link href="/" className="text-2xl font-bold font-display text-pink-500">
FotoSpiel.App
Die FotoSpiel.App
</Link>
<p className="text-gray-600 font-sans-marketing mt-2">
Deine Plattform für Event-Fotos.
@@ -57,7 +57,7 @@ const Footer: React.FC = () => {
</div>
<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>
</footer>

View File

@@ -121,7 +121,7 @@ const Header: React.FC = () => {
<Link href={localizedPath('/')} className="flex items-center gap-4">
<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">
FotoSpiel.App
Die FotoSpiel.App
</span>
</Link>
<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 { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react';
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;
@@ -18,6 +19,8 @@ interface RegisterFormProps {
onSuccess?: (payload: RegisterSuccessPayload) => void;
privacyHtml: string;
locale?: string;
prefill?: GoogleProfilePrefill;
onClearGoogleProfile?: () => void;
}
type RegisterFormFields = {
@@ -30,13 +33,15 @@ type RegisterFormFields = {
address: string;
phone: string;
privacy_consent: boolean;
terms: boolean;
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 [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [prefillApplied, setPrefillApplied] = useState(false);
const { t } = useTranslation(['auth', 'common']);
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
const resolvedLocale = locale ?? page.props.locale ?? 'de';
@@ -51,6 +56,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
address: '',
phone: '',
privacy_consent: false,
terms: false,
package_id: packageId || null,
});
@@ -62,6 +68,62 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
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) => {
event.preventDefault();
setHasTriedSubmit(true);
@@ -95,6 +157,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
redirect: json?.redirect ?? null,
pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false,
});
onClearGoogleProfile?.();
reset();
setHasTriedSubmit(false);
return;
@@ -362,9 +425,13 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
checked={data.privacy_consent}
onChange={(e) => {
setData('privacy_consent', e.target.checked);
setData('terms', e.target.checked);
if (e.target.checked && errors.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"
/>
@@ -379,6 +446,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
</button>.
</label>
{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>
@@ -419,8 +487,3 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
}

View File

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

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 { Steps } from "@/components/ui/Steps";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
import type { CheckoutPackage, CheckoutStepId } from "./types";
import type { CheckoutPackage, CheckoutStepId, GoogleProfilePrefill } from "./types";
import { PackageStep } from "./steps/PackageStep";
import { AuthStep } from "./steps/AuthStep";
import { ConfirmationStep } from "./steps/ConfirmationStep";
@@ -15,8 +15,6 @@ const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({
interface CheckoutWizardProps {
initialPackage: CheckoutPackage;
packageOptions: CheckoutPackage[];
stripePublishableKey: string;
paypalClientId: string;
privacyHtml: string;
initialAuthUser?: {
id: number;
@@ -25,6 +23,11 @@ interface CheckoutWizardProps {
pending_purchase?: boolean;
} | null;
initialStep?: CheckoutStepId;
googleProfile?: GoogleProfilePrefill | null;
paddle?: {
environment?: string | null;
client_token?: string | null;
} | null;
}
const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: string; detailsKey: string }[] = [
@@ -61,13 +64,34 @@ const PaymentStepFallback: React.FC = () => (
</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 { currentStep, nextStep, previousStep } = useCheckoutWizard();
const {
currentStep,
nextStep,
previousStep,
selectedPackage,
authUser,
isAuthenticated,
paymentCompleted,
} = useCheckoutWizard();
const progressRef = useRef<HTMLDivElement | null>(null);
const hasMountedRef = useRef(false);
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(() =>
baseStepConfig.map(step => ({
id: step.id,
@@ -114,7 +138,41 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
});
}, [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(() => {
if (!canProceedToNextStep) {
return;
}
const targetStep = stepConfig[currentIndex + 1]?.id ?? 'end';
trackEvent({
category: 'marketing_checkout',
@@ -122,7 +180,7 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
name: `${currentStep}->${targetStep}`,
});
nextStep();
}, [currentIndex, currentStep, nextStep, stepConfig, trackEvent]);
}, [canProceedToNextStep, currentIndex, currentStep, nextStep, stepConfig, trackEvent]);
const handlePrevious = useCallback(() => {
const targetStep = stepConfig[currentIndex - 1]?.id ?? 'start';
@@ -151,10 +209,16 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
<div className="space-y-6">
{currentStep === "package" && <PackageStep />}
{currentStep === "auth" && <AuthStep privacyHtml={privacyHtml} />}
{currentStep === "auth" && (
<AuthStep
privacyHtml={privacyHtml}
googleProfile={googleProfile ?? undefined}
onClearGoogleProfile={onClearGoogleProfile}
/>
)}
{currentStep === "payment" && (
<Suspense fallback={<PaymentStepFallback />}>
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} />
<PaymentStep />
</Suspense>
)}
{currentStep === "confirmation" && (
@@ -162,13 +226,17 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
)}
</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}>
{t('checkout.back')}
</Button>
<Button onClick={handleNext} disabled={currentIndex >= stepConfig.length - 1}>
{t('checkout.next')}
</Button>
{shouldShowNextButton ? (
<Button onClick={handleNext} disabled={!canProceedToNextStep}>
{t('checkout.next')}
</Button>
) : (
<div className="h-10 min-w-[128px]" aria-hidden="true" />
)}
</div>
</div>
);
@@ -177,12 +245,51 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialPackage,
packageOptions,
stripePublishableKey,
paypalClientId,
privacyHtml,
initialAuthUser,
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 (
<CheckoutWizardProvider
@@ -191,8 +298,13 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialStep={initialStep}
initialAuthUser={initialAuthUser ?? undefined}
initialIsAuthenticated={Boolean(initialAuthUser)}
paddle={paddle ?? null}
>
<WizardBody stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} privacyHtml={privacyHtml} />
<WizardBody
privacyHtml={privacyHtml}
googleProfile={effectiveProfile}
onClearGoogleProfile={clearStoredProfile}
/>
</CheckoutWizardProvider>
);
};

View File

@@ -10,6 +10,7 @@ interface CheckoutState {
paymentIntent: string | null;
loading: boolean;
error: string | null;
paymentCompleted: boolean;
}
interface CheckoutWizardContextType {
@@ -19,6 +20,11 @@ interface CheckoutWizardContextType {
currentStep: CheckoutStepId;
isAuthenticated: boolean;
authUser: any;
paddleConfig?: {
environment?: string | null;
client_token?: string | null;
} | null;
paymentCompleted: boolean;
selectPackage: (pkg: CheckoutPackage) => void;
setSelectedPackage: (pkg: CheckoutPackage) => void;
setAuthUser: (user: any) => void;
@@ -31,6 +37,7 @@ interface CheckoutWizardContextType {
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
resetPaymentState: () => void;
setPaymentCompleted: (completed: boolean) => void;
}
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
@@ -44,6 +51,7 @@ const initialState: CheckoutState = {
paymentIntent: null,
loading: false,
error: null,
paymentCompleted: false,
};
type CheckoutAction =
@@ -54,14 +62,15 @@ type CheckoutAction =
| { type: 'GO_TO_STEP'; payload: CheckoutStepId }
| { type: 'UPDATE_PAYMENT_INTENT'; payload: string | null }
| { 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 {
switch (action.type) {
case 'SELECT_PACKAGE':
return { ...state, selectedPackage: action.payload };
return { ...state, selectedPackage: action.payload, paymentCompleted: false };
case 'SET_AUTH_USER':
return { ...state, authUser: action.payload };
return { ...state, authUser: action.payload, isAuthenticated: Boolean(action.payload) };
case 'NEXT_STEP':
const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
const currentIndex = steps.indexOf(state.currentStep);
@@ -84,6 +93,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload };
case 'SET_PAYMENT_COMPLETED':
return { ...state, paymentCompleted: action.payload };
default:
return state;
}
@@ -96,6 +107,10 @@ interface CheckoutWizardProviderProps {
initialStep?: CheckoutStepId;
initialAuthUser?: any;
initialIsAuthenticated?: boolean;
paddle?: {
environment?: string | null;
client_token?: string | null;
} | null;
}
export function CheckoutWizardProvider({
@@ -104,7 +119,8 @@ export function CheckoutWizardProvider({
packageOptions,
initialStep,
initialAuthUser,
initialIsAuthenticated
initialIsAuthenticated,
paddle,
}: CheckoutWizardProviderProps) {
const customInitialState: CheckoutState = {
...initialState,
@@ -124,7 +140,8 @@ export function CheckoutWizardProvider({
if (savedState) {
try {
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
if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
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: 'SET_LOADING', payload: false });
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(() => {
@@ -226,6 +248,8 @@ export function CheckoutWizardProvider({
currentStep: state.currentStep,
isAuthenticated: state.isAuthenticated,
authUser: state.authUser,
paddleConfig: paddle ?? null,
paymentCompleted: state.paymentCompleted,
selectPackage,
setSelectedPackage,
setAuthUser,
@@ -238,6 +262,7 @@ export function CheckoutWizardProvider({
setLoading,
setError,
resetPaymentState,
setPaymentCompleted,
};
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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
import type { GoogleProfilePrefill } from '../types';
import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
import { useTranslation } from 'react-i18next';
@@ -11,6 +12,8 @@ import { LoaderCircle } from "lucide-react";
interface AuthStepProps {
privacyHtml: string;
googleProfile?: GoogleProfilePrefill;
onClearGoogleProfile?: () => void;
}
type GoogleAuthFlash = {
@@ -29,7 +32,7 @@ const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
</svg>
);
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => {
const { t } = useTranslation('marketing');
const page = usePage<{ locale?: string }>();
const locale = page.props.locale ?? "de";
@@ -42,10 +45,11 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
useEffect(() => {
if (googleAuth?.status === 'success') {
if (googleAuth?.status === 'signin') {
toast.success(t('checkout.auth_step.google_success_toast'));
onClearGoogleProfile?.();
}
}, [googleAuth?.status, t]);
}, [googleAuth?.status, onClearGoogleProfile, t]);
useEffect(() => {
if (googleAuth?.error) {
@@ -64,6 +68,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
name: payload.name ?? undefined,
pending_purchase: Boolean(payload.pending_purchase),
});
onClearGoogleProfile?.();
nextStep();
};
@@ -78,6 +83,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
});
}
onClearGoogleProfile?.();
nextStep();
};
@@ -158,6 +164,8 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
privacyHtml={privacyHtml}
locale={locale}
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 { 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 { Button } from '@/components/ui/button';
import { LoaderCircle } from 'lucide-react';
import { useCheckoutWizard } from '../WizardContext';
import { getStripe } from '@/utils/stripe';
interface PaymentStepProps {
stripePublishableKey: string;
paypalClientId: string;
}
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
type Provider = 'stripe' | 'paypal';
type PaymentStatus = 'idle' | 'loading' | 'ready' | 'processing' | 'error' | 'success';
interface StripePaymentFormProps {
onProcessing: () => 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);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{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 createOrder = async () => {
if (!selectedPackage?.id) {
const message = t('checkout.payment_step.paypal_order_error');
onError(message);
throw new Error(message);
}
try {
onProcessing();
const endpoint = isReseller ? '/paypal/create-subscription' : '/paypal/create-order';
const payload: Record<string, unknown> = {
package_id: selectedPackage.id,
declare global {
interface Window {
Paddle?: {
Environment?: {
set: (environment: string) => void;
};
Initialize?: (options: { token: string }) => void;
Checkout: {
open: (options: Record<string, unknown>) => void;
};
};
}
}
if (isReseller) {
if (!paypalPlanId) {
const message = t('checkout.payment_step.paypal_missing_plan');
onError(message);
throw new Error(message);
}
payload.plan_id = paypalPlanId;
}
const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js';
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),
});
type PaddleEnvironment = 'sandbox' | 'production';
const data = await response.json();
let paddleLoaderPromise: Promise<typeof window.Paddle | null> | null = null;
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'));
}
function configurePaddle(paddle: typeof window.Paddle | undefined | null, environment: PaddleEnvironment): typeof window.Paddle | null {
if (!paddle) {
return null;
}
throw new Error('Failed to create PayPal order');
} catch (error) {
console.error('PayPal create order failed', error);
onError(t('checkout.payment_step.network_error'));
throw error;
}
};
try {
paddle.Environment?.set?.(environment);
} catch (error) {
console.warn('[Paddle] Failed to set environment', error);
}
const onApprove = async (data: any) => {
try {
const response = await fetch('/paypal/capture-order', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify({ order_id: data.orderID }),
});
return paddle;
}
const result = await response.json();
async function loadPaddle(environment: PaddleEnvironment): Promise<typeof window.Paddle | null> {
if (typeof window === 'undefined') {
return null;
}
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);
onError(t('checkout.payment_step.network_error'));
}
};
if (window.Paddle) {
return configurePaddle(window.Paddle, environment);
}
const handleError = (error: unknown) => {
console.error('PayPal error', error);
onError(t('checkout.payment_step.paypal_error'));
};
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 handleCancel = () => {
onError(t('checkout.payment_step.paypal_cancelled'));
};
const paddle = await paddleLoaderPromise;
return configurePaddle(paddle, environment);
}
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean }> = ({ onCheckout, disabled, isProcessing }) => {
const { t } = useTranslation('marketing');
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>
<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>
);
};
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 }) => {
export const PaymentStep: React.FC = () => {
const { t } = useTranslation('marketing');
const { selectedPackage, authUser, nextStep, resetPaymentState } = useCheckoutWizard();
const [paymentMethod, setPaymentMethod] = useState<Provider>('stripe');
const [clientSecret, setClientSecret] = useState('');
const { selectedPackage, nextStep, paddleConfig, authUser, setPaymentCompleted } = useCheckoutWizard();
const [status, setStatus] = useState<PaymentStatus>('idle');
const [statusDetail, setStatusDetail] = useState<string>('');
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null);
const [message, setMessage] = useState<string>('');
const [initialised, setInitialised] = useState(false);
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 ? selectedPackage.price <= 0 : false), [selectedPackage]);
const isReseller = selectedPackage?.type === 'reseller';
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
const paypalPlanId = useMemo(() => {
if (!selectedPackage) {
return null;
}
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 handleFreeActivation = async () => {
setPaymentCompleted(true);
nextStep();
};
const startPaddleCheckout = async () => {
if (!selectedPackage) {
return;
}
if (paymentMethod === 'paypal') {
if (paypalDisabled) {
setStatus('error');
setStatusDetail(t('checkout.payment_step.paypal_missing_plan'));
} else {
setStatus('ready');
setStatusDetail('');
}
return;
}
if (!stripePromise) {
if (!selectedPackage.paddle_price_id) {
setStatus('error');
setStatusDetail(t('checkout.payment_step.stripe_not_loaded'));
setMessage(t('checkout.payment_step.paddle_not_configured'));
return;
}
if (!authUser) {
setStatus('error');
setStatusDetail(t('checkout.payment_step.auth_required'));
return;
}
setPaymentCompleted(false);
setStatus('processing');
setMessage(t('checkout.payment_step.paddle_preparing'));
setInlineActive(false);
let cancelled = false;
setStatus('loading');
setStatusDetail(t('checkout.payment_step.status_loading'));
setClientSecret('');
try {
const inlineSupported = initialised && !!paddleConfig?.client_token;
const loadIntent = async () => {
try {
const response = await fetch('/stripe/create-payment-intent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify({ package_id: selectedPackage.id }),
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,
});
}
const data = await response.json();
if (inlineSupported) {
const paddle = paddleRef.current;
if (!response.ok || !data.client_secret) {
const message = data.error || t('checkout.payment_step.payment_intent_error');
if (!cancelled) {
setStatus('error');
setStatusDetail(message);
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',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify({
package_id: selectedPackage.id,
}),
});
const rawBody = await response.text();
if (typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
}
let data: any = null;
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];
}
return;
}
}
if (!cancelled) {
setClientSecret(data.client_secret);
setStatus('ready');
setStatusDetail(t('checkout.payment_step.status_ready'));
}
} catch (error) {
if (!cancelled) {
console.error('Failed to load payment intent', error);
setStatus('error');
setStatusDetail(t('checkout.payment_step.network_error'));
}
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');
setMessage(t('checkout.payment_step.paddle_ready'));
} catch (error) {
console.error('Failed to start Paddle checkout', error);
setStatus('error');
setMessage(t('checkout.payment_step.paddle_error'));
setInlineActive(false);
setPaymentCompleted(false);
}
};
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);
}
};
loadIntent();
(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 () => {
cancelled = true;
};
}, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, stripePromise, t]);
}, [paddleConfig?.environment, paddleConfig?.client_token, setPaymentCompleted, t]);
const providerLabel = useCallback((provider: Provider) => {
switch (provider) {
case 'paypal':
return 'PayPal';
default:
return 'Stripe';
}
}, []);
useEffect(() => {
setPaymentCompleted(false);
}, [selectedPackage?.id, setPaymentCompleted]);
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 (!selectedPackage) {
return (
<Alert variant="destructive">
<AlertTitle>{t('checkout.payment_step.no_package_title')}</AlertTitle>
<AlertDescription>{t('checkout.payment_step.no_package_description')}</AlertDescription>
</Alert>
);
}
if (isFree) {
return (
@@ -405,7 +336,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
<Button size="lg" onClick={handleFreeActivation}>
{t('checkout.payment_step.activate_package')}
</Button>
</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 (
<div className="space-y-6">
<div className="flex flex-wrap gap-3">
<Button
variant={paymentMethod === 'stripe' ? 'default' : 'outline'}
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>
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t('checkout.payment_step.paddle_intro')}
</p>
{renderStatusAlert()}
{paymentMethod === 'stripe' && clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<StripePaymentForm
selectedPackage={selectedPackage}
onProcessing={() => handleProcessing('stripe')}
onSuccess={() => handleSuccess('stripe')}
onError={(message) => handleError('stripe', message)}
t={t}
/>
</Elements>
)}
{paymentMethod === 'stripe' && !clientSecret && status === 'loading' && (
<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>
{status !== 'idle' && (
<Alert variant={status === 'error' ? 'destructive' : 'secondary'}>
<AlertTitle>
{status === 'processing'
? t('checkout.payment_step.status_processing_title')
: status === 'ready'
? t('checkout.payment_step.status_ready_title')
: status === 'error'
? t('checkout.payment_step.status_error_title')
: t('checkout.payment_step.status_info_title')}
</AlertTitle>
<AlertDescription className="flex items-center gap-3">
<span>{message}</span>
{status === 'processing' && <LoaderCircle className="h-4 w-4 animate-spin" />}
</AlertDescription>
</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>
);
};

View File

@@ -1,5 +1,14 @@
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 {
id: number;
name: string;
@@ -15,7 +24,8 @@ export interface CheckoutPackage {
type: 'endcustomer' | 'reseller';
features: string[];
limits?: Record<string, unknown>;
paypal_plan_id?: string | null;
paddle_price_id?: string | null;
paddle_product_id?: string | null;
[key: string]: unknown;
}
@@ -30,7 +40,7 @@ export interface CheckoutWizardState {
name?: string;
pending_purchase?: boolean;
} | null;
paymentProvider?: 'stripe' | 'paypal';
paymentProvider?: 'stripe' | 'paddle';
isProcessing?: boolean;
}

View File

@@ -12,14 +12,14 @@ return [
'contact' => 'Kontakt',
'vat_id' => 'Umsatzsteuer-ID: DE123456789',
'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',
'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.',
'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.',
'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.',
'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.',
'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.',
@@ -34,5 +34,4 @@ return [
'version' => 'Version :version',
'and' => 'und',
'stripe_privacy' => 'Stripe Datenschutz',
'paypal_privacy' => 'PayPal Datenschutz',
];

View File

@@ -67,7 +67,7 @@
"faq_q3": "Was passiert bei Ablauf?",
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
"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?",
"contact_us": "Kontaktieren Sie uns",
"feature_live_slideshow": "Live-Slideshow",

View File

@@ -30,7 +30,7 @@ return [
'faq_q3' => 'Was passiert bei Ablauf?',
'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.',
'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?',
'contact_us' => 'Kontaktieren Sie uns',
'feature_live_slideshow' => 'Live-Slideshow',
@@ -60,10 +60,12 @@ return [
'max_guests_label' => 'Max. Gäste',
'gallery_days_label' => 'Galerie-Tage',
'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',
'breakdown_label' => 'Leistungsübersicht',
'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' => [
'home' => 'Startseite',

View File

@@ -12,14 +12,14 @@ return [
'contact' => 'Contact',
'vat_id' => 'VAT ID: DE123456789',
'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',
'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.',
'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.',
'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.',
'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.',
'cookies' => 'Cookies: Only functional cookies for the PWA.',

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