switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.
This commit is contained in:
14
.env.example
14
.env.example
@@ -84,6 +84,20 @@ REVENUECAT_WEBHOOK_QUEUE=webhooks
|
|||||||
CHECKOUT_WIZARD_ENABLED=true
|
CHECKOUT_WIZARD_ENABLED=true
|
||||||
CHECKOUT_WIZARD_FLAG=checkout-wizard-2025
|
CHECKOUT_WIZARD_FLAG=checkout-wizard-2025
|
||||||
|
|
||||||
|
# PayPal
|
||||||
|
PAYPAL_CLIENT_ID=
|
||||||
|
PAYPAL_SECRET=
|
||||||
|
PAYPAL_SANDBOX=true
|
||||||
|
|
||||||
|
# Paddle Billing
|
||||||
|
PADDLE_SANDBOX=true
|
||||||
|
PADDLE_API_KEY=
|
||||||
|
PADDLE_CLIENT_ID=
|
||||||
|
PADDLE_WEBHOOK_SECRET=
|
||||||
|
PADDLE_PUBLIC_KEY=
|
||||||
|
PADDLE_BASE_URL=
|
||||||
|
PADDLE_CONSOLE_URL=
|
||||||
|
|
||||||
OAUTH_JWT_KID=fotospiel-jwt
|
OAUTH_JWT_KID=fotospiel-jwt
|
||||||
OAUTH_KEY_STORE=
|
OAUTH_KEY_STORE=
|
||||||
OAUTH_REFRESH_ENFORCE_IP=true
|
OAUTH_REFRESH_ENFORCE_IP=true
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev).
|
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev).
|
||||||
- Git Hosting: Gogs at http://nas:10880 (token found locally in gogs.ini, never printed or committed).
|
- Git Hosting: Gogs at http://nas:10880 (token found locally in gogs.ini, never printed or committed).
|
||||||
- Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth).
|
- Issue API: Gogs REST /api/v1 for labels/issues/milestones (token auth).
|
||||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Stripe PHP SDK for payments; PayPal Server SDK for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n.
|
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n.
|
||||||
- Payment Systems: Stripe (subscriptions and one-time payments), PayPal (integrated payments), RevenueCat (mobile app subscriptions).
|
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions).
|
||||||
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
||||||
|
|
||||||
## Repo Structure (high-level)
|
## Repo Structure (high-level)
|
||||||
@@ -59,7 +59,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
- tenant:add-dummy — create a demo tenant and admin user (see --help for options).
|
- tenant:add-dummy — create a demo tenant and admin user (see --help for options).
|
||||||
- tenant:attach-demo-event — attach an existing demo event to a tenant.
|
- tenant:attach-demo-event — attach an existing demo event to a tenant.
|
||||||
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md.
|
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/prp/03-api.md.
|
||||||
- Payment Integration: Stripe webhooks, PayPal API integration, RevenueCat mobile subscriptions.
|
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
|
||||||
|
|
||||||
## PWA Architecture
|
## PWA Architecture
|
||||||
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
||||||
|
|||||||
114
app/Console/Commands/PaddleSyncPackages.php
Normal file
114
app/Console/Commands/PaddleSyncPackages.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\PackageResource\Pages;
|
use App\Filament\Resources\PackageResource\Pages;
|
||||||
|
use App\Jobs\PullPackageFromPaddle;
|
||||||
|
use App\Jobs\SyncPackageToPaddle;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -11,21 +15,23 @@ use Filament\Actions\EditAction;
|
|||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\ViewAction;
|
||||||
use Filament\Forms\Components\CheckboxList;
|
use Filament\Forms\Components\CheckboxList;
|
||||||
use Filament\Forms\Components\MarkdownEditor;
|
use Filament\Forms\Components\MarkdownEditor;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Repeater;
|
use Filament\Forms\Components\Repeater;
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
|
||||||
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Columns\BadgeColumn;
|
use Filament\Tables\Columns\BadgeColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
use BackedEnum;
|
|
||||||
|
|
||||||
class PackageResource extends Resource
|
class PackageResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -150,6 +156,28 @@ class PackageResource extends Resource
|
|||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->default([]),
|
->default([]),
|
||||||
]),
|
]),
|
||||||
|
Section::make('Paddle Billing')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
TextInput::make('paddle_product_id')
|
||||||
|
->label('Paddle Produkt-ID')
|
||||||
|
->maxLength(191)
|
||||||
|
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
|
||||||
|
->placeholder('nicht verknüpft'),
|
||||||
|
TextInput::make('paddle_price_id')
|
||||||
|
->label('Paddle Preis-ID')
|
||||||
|
->maxLength(191)
|
||||||
|
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
|
||||||
|
->placeholder('nicht verknüpft'),
|
||||||
|
Placeholder::make('paddle_sync_status')
|
||||||
|
->label('Sync-Status')
|
||||||
|
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–')
|
||||||
|
->columnSpanFull(),
|
||||||
|
Placeholder::make('paddle_synced_at')
|
||||||
|
->label('Zuletzt synchronisiert')
|
||||||
|
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +242,28 @@ class PackageResource extends Resource
|
|||||||
->label('Features')
|
->label('Features')
|
||||||
->wrap()
|
->wrap()
|
||||||
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
||||||
|
TextColumn::make('paddle_product_id')
|
||||||
|
->label('Paddle Produkt')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
|
TextColumn::make('paddle_price_id')
|
||||||
|
->label('Paddle Preis')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
|
BadgeColumn::make('paddle_sync_status')
|
||||||
|
->label('Sync-Status')
|
||||||
|
->colors([
|
||||||
|
'success' => 'synced',
|
||||||
|
'warning' => 'syncing',
|
||||||
|
'info' => 'dry-run',
|
||||||
|
'danger' => ['failed', 'pull-failed'],
|
||||||
|
])
|
||||||
|
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_synced_at')
|
||||||
|
->label('Sync am')
|
||||||
|
->dateTime()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('type')
|
Tables\Filters\SelectFilter::make('type')
|
||||||
@@ -224,6 +274,35 @@ class PackageResource extends Resource
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('syncPaddle')
|
||||||
|
->label('Mit Paddle abgleichen')
|
||||||
|
->icon('heroicon-o-cloud-arrow-up')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing')
|
||||||
|
->action(function (Package $record) {
|
||||||
|
SyncPackageToPaddle::dispatch($record->id);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Paddle-Sync gestartet')
|
||||||
|
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('pullPaddle')
|
||||||
|
->label('Status von Paddle holen')
|
||||||
|
->icon('heroicon-o-cloud-arrow-down')
|
||||||
|
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (Package $record) {
|
||||||
|
PullPackageFromPaddle::dispatch($record->id);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->info()
|
||||||
|
->title('Paddle-Abgleich angefordert')
|
||||||
|
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
EditAction::make(),
|
EditAction::make(),
|
||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Filament\Resources;
|
|||||||
|
|
||||||
use App\Filament\Resources\PurchaseResource\Pages;
|
use App\Filament\Resources\PurchaseResource\Pages;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -11,23 +12,19 @@ use Filament\Actions\EditAction;
|
|||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\ViewAction;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Columns\BadgeColumn;
|
use Filament\Tables\Columns\BadgeColumn;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\Filter;
|
use Filament\Tables\Filters\Filter;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use BackedEnum;
|
|
||||||
use UnitEnum;
|
|
||||||
class PurchaseResource extends Resource
|
class PurchaseResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = PackagePurchase::class;
|
protected static ?string $model = PackagePurchase::class;
|
||||||
@@ -97,7 +94,7 @@ class PurchaseResource extends Resource
|
|||||||
->columns([
|
->columns([
|
||||||
BadgeColumn::make('type')
|
BadgeColumn::make('type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->color(fn (string $state): string => match($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
'endcustomer_event' => 'info',
|
'endcustomer_event' => 'info',
|
||||||
'reseller_subscription' => 'success',
|
'reseller_subscription' => 'success',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
@@ -164,11 +161,11 @@ class PurchaseResource extends Resource
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (PackagePurchase $record): bool => !$record->refunded)
|
->visible(fn (PackagePurchase $record): bool => ! $record->refunded)
|
||||||
->action(function (PackagePurchase $record) {
|
->action(function (PackagePurchase $record) {
|
||||||
$record->update(['refunded' => true]);
|
$record->update(['refunded' => true]);
|
||||||
// TODO: Call Stripe/PayPal API for actual refund
|
// TODO: Call Stripe/Paddle API for actual refund
|
||||||
Log::info('Refund processed for purchase ID: ' . $record->id);
|
Log::info('Refund processed for purchase ID: '.$record->id);
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ class ViewPurchase extends ViewRecord
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn ($record): bool => !$record->refunded)
|
->visible(fn ($record): bool => ! $record->refunded)
|
||||||
->action(function ($record) {
|
->action(function ($record) {
|
||||||
$record->update(['refunded' => true]);
|
$record->update(['refunded' => true]);
|
||||||
// TODO: Call Stripe/PayPal API for actual refund
|
// TODO: Call Stripe/Paddle API for actual refund
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,38 +3,37 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource\Pages;
|
use App\Filament\Resources\TenantResource\Pages;
|
||||||
use App\Models\Tenant;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Form;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Forms\Components\KeyValue;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager;
|
use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager;
|
||||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
||||||
use Filament\Resources\RelationManagers\RelationGroup;
|
use App\Models\Tenant;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use UnitEnum;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
class TenantResource extends Resource
|
class TenantResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = Tenant::class;
|
protected static ?string $model = Tenant::class;
|
||||||
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-building-office';
|
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-building-office';
|
||||||
|
|
||||||
protected static UnitEnum|string|null $navigationGroup = null;
|
protected static UnitEnum|string|null $navigationGroup = null;
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
{
|
{
|
||||||
return __('admin.nav.platform_management');
|
return __('admin.nav.platform_management');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static ?int $navigationSort = 10;
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
public static function form(Schema $form): Schema
|
public static function form(Schema $form): Schema
|
||||||
@@ -61,6 +60,11 @@ class TenantResource extends Resource
|
|||||||
->label(__('admin.tenants.fields.event_credits_balance'))
|
->label(__('admin.tenants.fields.event_credits_balance'))
|
||||||
->numeric()
|
->numeric()
|
||||||
->readOnly(),
|
->readOnly(),
|
||||||
|
TextInput::make('paddle_customer_id')
|
||||||
|
->label('Paddle Customer ID')
|
||||||
|
->maxLength(191)
|
||||||
|
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
|
||||||
|
->nullable(),
|
||||||
TextInput::make('total_revenue')
|
TextInput::make('total_revenue')
|
||||||
->label(__('admin.tenants.fields.total_revenue'))
|
->label(__('admin.tenants.fields.total_revenue'))
|
||||||
->prefix('€')
|
->prefix('€')
|
||||||
@@ -104,6 +108,10 @@ class TenantResource extends Resource
|
|||||||
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
||||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('contact_email'),
|
Tables\Columns\TextColumn::make('contact_email'),
|
||||||
|
Tables\Columns\TextColumn::make('paddle_customer_id')
|
||||||
|
->label('Paddle Customer')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
Tables\Columns\TextColumn::make('event_credits_balance')
|
Tables\Columns\TextColumn::make('event_credits_balance')
|
||||||
->label(__('admin.tenants.fields.event_credits_balance'))
|
->label(__('admin.tenants.fields.event_credits_balance'))
|
||||||
->badge()
|
->badge()
|
||||||
@@ -162,6 +170,7 @@ class TenantResource extends Resource
|
|||||||
\App\Models\PackagePurchase::create([
|
\App\Models\PackagePurchase::create([
|
||||||
'tenant_id' => $record->id,
|
'tenant_id' => $record->id,
|
||||||
'package_id' => $data['package_id'],
|
'package_id' => $data['package_id'],
|
||||||
|
'provider' => 'manual',
|
||||||
'provider_id' => 'manual',
|
'provider_id' => 'manual',
|
||||||
'type' => 'reseller_subscription',
|
'type' => 'reseller_subscription',
|
||||||
'price' => 0,
|
'price' => 0,
|
||||||
|
|||||||
@@ -2,23 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||||
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\ViewAction;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
||||||
|
|
||||||
class PackagePurchasesRelationManager extends RelationManager
|
class PackagePurchasesRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@@ -43,24 +39,23 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
'reseller_subscription' => 'Reseller-Abo',
|
'reseller_subscription' => 'Reseller-Abo',
|
||||||
])
|
])
|
||||||
->required(),
|
->required(),
|
||||||
TextInput::make('purchased_price')
|
Select::make('provider')
|
||||||
->label('Gekaufter Preis')
|
|
||||||
->numeric()
|
|
||||||
->step(0.01)
|
|
||||||
->prefix('€')
|
|
||||||
->required(),
|
|
||||||
Select::make('provider_id')
|
|
||||||
->label('Anbieter')
|
->label('Anbieter')
|
||||||
->options([
|
->options([
|
||||||
|
'paddle' => 'Paddle',
|
||||||
'stripe' => 'Stripe',
|
'stripe' => 'Stripe',
|
||||||
'paypal' => 'PayPal',
|
|
||||||
'manual' => 'Manuell',
|
'manual' => 'Manuell',
|
||||||
'free' => 'Kostenlos',
|
'free' => 'Kostenlos',
|
||||||
])
|
])
|
||||||
->required(),
|
->required(),
|
||||||
TextInput::make('transaction_id')
|
TextInput::make('provider_id')
|
||||||
->label('Transaktions-ID')
|
->label('Provider-Referenz')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
TextInput::make('price')
|
||||||
|
->label('Preis')
|
||||||
|
->numeric()
|
||||||
|
->step(0.01)
|
||||||
|
->prefix('€'),
|
||||||
Toggle::make('refunded')
|
Toggle::make('refunded')
|
||||||
->label('Rückerstattet'),
|
->label('Rückerstattet'),
|
||||||
Textarea::make('metadata')
|
Textarea::make('metadata')
|
||||||
@@ -82,7 +77,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
->color('success'),
|
->color('success'),
|
||||||
TextColumn::make('type')
|
TextColumn::make('type')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (string $state): string => match($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
'endcustomer_event' => 'info',
|
'endcustomer_event' => 'info',
|
||||||
'reseller_subscription' => 'success',
|
'reseller_subscription' => 'success',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
@@ -90,15 +85,17 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
TextColumn::make('price')
|
TextColumn::make('price')
|
||||||
->money('EUR')
|
->money('EUR')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('provider_id')
|
TextColumn::make('provider')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (string $state): string => match($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'paddle' => 'success',
|
||||||
'stripe' => 'info',
|
'stripe' => 'info',
|
||||||
'paypal' => 'warning',
|
|
||||||
'manual' => 'gray',
|
'manual' => 'gray',
|
||||||
'free' => 'success',
|
'free' => 'success',
|
||||||
|
default => 'gray',
|
||||||
}),
|
}),
|
||||||
TextColumn::make('transaction_id')
|
TextColumn::make('provider_id')
|
||||||
|
->label('Provider-Referenz')
|
||||||
->copyable()
|
->copyable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('metadata')
|
TextColumn::make('metadata')
|
||||||
@@ -117,10 +114,10 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
'endcustomer_event' => 'Endkunden-Event',
|
'endcustomer_event' => 'Endkunden-Event',
|
||||||
'reseller_subscription' => 'Reseller-Abo',
|
'reseller_subscription' => 'Reseller-Abo',
|
||||||
]),
|
]),
|
||||||
SelectFilter::make('provider_id')
|
SelectFilter::make('provider')
|
||||||
->options([
|
->options([
|
||||||
|
'paddle' => 'Paddle',
|
||||||
'stripe' => 'Stripe',
|
'stripe' => 'Stripe',
|
||||||
'paypal' => 'PayPal',
|
|
||||||
'manual' => 'Manuell',
|
'manual' => 'Manuell',
|
||||||
'free' => 'Kostenlos',
|
'free' => 'Kostenlos',
|
||||||
]),
|
]),
|
||||||
@@ -141,4 +138,3 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||||
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
||||||
|
|
||||||
class TenantPackagesRelationManager extends RelationManager
|
class TenantPackagesRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@@ -40,6 +37,11 @@ class TenantPackagesRelationManager extends RelationManager
|
|||||||
DateTimePicker::make('expires_at')
|
DateTimePicker::make('expires_at')
|
||||||
->label('Ablaufdatum')
|
->label('Ablaufdatum')
|
||||||
->required(),
|
->required(),
|
||||||
|
TextInput::make('paddle_subscription_id')
|
||||||
|
->label('Paddle Subscription ID')
|
||||||
|
->maxLength(191)
|
||||||
|
->helperText('Abonnement-ID aus Paddle Billing.')
|
||||||
|
->nullable(),
|
||||||
Toggle::make('active')
|
Toggle::make('active')
|
||||||
->label('Aktiv'),
|
->label('Aktiv'),
|
||||||
Textarea::make('reason')
|
Textarea::make('reason')
|
||||||
@@ -70,6 +72,10 @@ class TenantPackagesRelationManager extends RelationManager
|
|||||||
TextColumn::make('expires_at')
|
TextColumn::make('expires_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
TextColumn::make('paddle_subscription_id')
|
||||||
|
->label('Paddle Subscription')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
IconColumn::make('active')
|
IconColumn::make('active')
|
||||||
->boolean()
|
->boolean()
|
||||||
->color(fn (bool $state): string => $state ? 'success' : 'danger'),
|
->color(fn (bool $state): string => $state ? 'success' : 'danger'),
|
||||||
|
|||||||
@@ -5,21 +5,17 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use PayPal\Checkout\Orders\OrdersCaptureRequest;
|
|
||||||
use PayPal\Checkout\Orders\OrdersCreateRequest;
|
|
||||||
use PayPal\Environment\LiveEnvironment;
|
|
||||||
use PayPal\Environment\SandboxEnvironment;
|
|
||||||
use PayPal\PayPalClient;
|
|
||||||
|
|
||||||
class PackageController extends Controller
|
class PackageController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$type = $request->query('type', 'endcustomer');
|
$type = $request->query('type', 'endcustomer');
|
||||||
@@ -51,7 +47,7 @@ class PackageController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'type' => 'required|in:endcustomer,reseller',
|
'type' => 'required|in:endcustomer,reseller',
|
||||||
'payment_method' => 'required|in:stripe,paypal',
|
'payment_method' => 'required|in:stripe,paddle',
|
||||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -105,8 +101,8 @@ class PackageController extends Controller
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'payment_method_id' => 'required_without:paypal_order_id|string',
|
'payment_method_id' => 'required_without:paddle_transaction_id|string',
|
||||||
'paypal_order_id' => 'required_without:payment_method_id|string',
|
'paddle_transaction_id' => 'required_without:payment_method_id|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$package = Package::findOrFail($request->package_id);
|
$package = Package::findOrFail($request->package_id);
|
||||||
@@ -116,13 +112,14 @@ class PackageController extends Controller
|
|||||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$provider = $request->has('paypal_order_id') ? 'paypal' : 'stripe';
|
$provider = $request->has('paddle_transaction_id') ? 'paddle' : 'stripe';
|
||||||
|
|
||||||
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
||||||
PackagePurchase::create([
|
PackagePurchase::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'provider_id' => $request->input($provider === 'paypal' ? 'paypal_order_id' : 'payment_method_id'),
|
'provider' => $provider,
|
||||||
|
'provider_id' => $request->input($provider === 'paddle' ? 'paddle_transaction_id' : 'payment_method_id'),
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'type' => 'endcustomer_event',
|
'type' => 'endcustomer_event',
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
@@ -165,6 +162,7 @@ class PackageController extends Controller
|
|||||||
PackagePurchase::create([
|
PackagePurchase::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
|
'provider' => 'free',
|
||||||
'provider_id' => 'free_wizard',
|
'provider_id' => 'free_wizard',
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'type' => 'endcustomer_event',
|
'type' => 'endcustomer_event',
|
||||||
@@ -186,156 +184,33 @@ class PackageController extends Controller
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createPayPalOrder(Request $request): JsonResponse
|
public function createPaddleCheckout(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
|
'success_url' => 'nullable|url',
|
||||||
|
'return_url' => 'nullable|url',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$package = Package::findOrFail($request->package_id);
|
$package = Package::findOrFail($request->integer('package_id'));
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment(
|
if (! $package->paddle_price_id) {
|
||||||
config('services.paypal.client_id'),
|
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||||
config('services.paypal.secret')
|
}
|
||||||
) : new LiveEnvironment(
|
|
||||||
config('services.paypal.client_id'),
|
|
||||||
config('services.paypal.secret')
|
|
||||||
);
|
|
||||||
|
|
||||||
$client = PayPalClient::client($environment);
|
$payload = [
|
||||||
|
'success_url' => $request->input('success_url'),
|
||||||
$request = new OrdersCreateRequest;
|
'return_url' => $request->input('return_url'),
|
||||||
$request->prefer('return=representation');
|
|
||||||
$request->body = [
|
|
||||||
'intent' => 'CAPTURE',
|
|
||||||
'purchase_units' => [[
|
|
||||||
'amount' => [
|
|
||||||
'currency_code' => 'EUR',
|
|
||||||
'value' => number_format($package->price, 2, '.', ''),
|
|
||||||
],
|
|
||||||
'description' => 'Fotospiel Package: '.$package->name,
|
|
||||||
'custom_id' => json_encode([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'user_id' => $tenant->user_id ?? null,
|
|
||||||
]),
|
|
||||||
]],
|
|
||||||
'application_context' => [
|
|
||||||
'shipping_preference' => 'NO_SHIPPING',
|
|
||||||
'user_action' => 'PAY_NOW',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||||
$response = $client->execute($request);
|
|
||||||
$order = $response->result;
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json($checkout);
|
||||||
'orderID' => $order->id,
|
|
||||||
]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('PayPal order creation error: '.$e->getMessage());
|
|
||||||
throw ValidationException::withMessages(['payment' => 'PayPal-Bestellung fehlgeschlagen.']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function capturePayPalOrder(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'order_id' => 'required|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$orderId = $request->order_id;
|
|
||||||
|
|
||||||
$environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment(
|
|
||||||
config('services.paypal.client_id'),
|
|
||||||
config('services.paypal.secret')
|
|
||||||
) : new LiveEnvironment(
|
|
||||||
config('services.paypal.client_id'),
|
|
||||||
config('services.paypal.secret')
|
|
||||||
);
|
|
||||||
|
|
||||||
$client = PayPalClient::client($environment);
|
|
||||||
|
|
||||||
$request = new OrdersCaptureRequest($orderId);
|
|
||||||
$request->prefer('return=representation');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = $client->execute($request);
|
|
||||||
$capture = $response->result;
|
|
||||||
|
|
||||||
if ($capture->status !== 'COMPLETED') {
|
|
||||||
throw new \Exception('PayPal capture not completed: '.$capture->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
$customId = $capture->purchaseUnits[0]->customId ?? null;
|
|
||||||
if (! $customId) {
|
|
||||||
throw new \Exception('No metadata in PayPal order');
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = json_decode($customId, true);
|
|
||||||
$tenant = Tenant::find($metadata['tenant_id']);
|
|
||||||
$package = Package::find($metadata['package_id']);
|
|
||||||
|
|
||||||
if (! $tenant || ! $package) {
|
|
||||||
throw new \Exception('Tenant or package not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotent check
|
|
||||||
$existing = PackagePurchase::where('provider_id', $orderId)->first();
|
|
||||||
if ($existing) {
|
|
||||||
return response()->json(['success' => true, 'message' => 'Already processed']);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::transaction(function () use ($tenant, $package, $orderId) {
|
|
||||||
PackagePurchase::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'provider_id' => $orderId,
|
|
||||||
'price' => $package->price,
|
|
||||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
|
||||||
'purchased_at' => now(),
|
|
||||||
'metadata' => json_encode(['paypal_order' => $orderId]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Trial logic for first reseller subscription
|
|
||||||
$activePackages = TenantPackage::where('tenant_id', $tenant->id)
|
|
||||||
->where('active', true)
|
|
||||||
->where('package_id', '!=', $package->id) // Exclude current if renewing
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$expiresAt = now()->addYear();
|
|
||||||
if ($activePackages === 0 && $package->type === 'reseller_subscription') {
|
|
||||||
$expiresAt = now()->addDays(14); // 14-day trial
|
|
||||||
Log::info('PayPal trial activated for tenant', ['tenant_id' => $tenant->id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
|
||||||
['tenant_id' => $tenant->id, 'package_id' => $package->id],
|
|
||||||
[
|
|
||||||
'price' => $package->price,
|
|
||||||
'purchased_at' => now(),
|
|
||||||
'active' => true,
|
|
||||||
'expires_at' => $expiresAt,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$tenant->update(['subscription_status' => 'active']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Log::info('PayPal order captured successfully', ['order_id' => $orderId, 'tenant_id' => $tenant->id]);
|
|
||||||
|
|
||||||
return response()->json(['success' => true, 'message' => 'Payment successful']);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('PayPal capture error: '.$e->getMessage(), ['order_id' => $orderId]);
|
|
||||||
|
|
||||||
return response()->json(['success' => false, 'message' => 'Capture failed: '.$e->getMessage()], 422);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||||
@@ -345,6 +220,7 @@ class PackageController extends Controller
|
|||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'event_id' => $request->event_id,
|
'event_id' => $request->event_id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
|
'provider' => 'free',
|
||||||
'provider_id' => 'free',
|
'provider_id' => 'free',
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'type' => $request->type,
|
'type' => $request->type,
|
||||||
@@ -397,7 +273,4 @@ class PackageController extends Controller
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper for PayPal client - add this if not exists, or use global
|
|
||||||
// But since SDK has PayPalClient, assume it's used
|
|
||||||
}
|
}
|
||||||
|
|||||||
63
app/Http/Controllers/Api/TenantBillingController.php
Normal file
63
app/Http/Controllers/Api/TenantBillingController.php
Normal 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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,19 +3,18 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rules;
|
use Illuminate\Validation\Rules;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
use App\Models\Tenant;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
|
|
||||||
class RegisteredUserController extends Controller
|
class RegisteredUserController extends Controller
|
||||||
{
|
{
|
||||||
@@ -38,7 +37,7 @@ class RegisteredUserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$fullName = trim($request->first_name . ' ' . $request->last_name);
|
$fullName = trim($request->first_name.' '.$request->last_name);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
|
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
|
||||||
@@ -73,7 +72,7 @@ class RegisteredUserController extends Controller
|
|||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'name' => $fullName,
|
'name' => $fullName,
|
||||||
'slug' => Str::slug($fullName . '-' . now()->timestamp),
|
'slug' => Str::slug($fullName.'-'.now()->timestamp),
|
||||||
'email' => $request->email,
|
'email' => $request->email,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'is_suspended' => false,
|
'is_suspended' => false,
|
||||||
@@ -123,6 +122,7 @@ class RegisteredUserController extends Controller
|
|||||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
||||||
'price' => 0,
|
'price' => 0,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
|
'provider' => 'free',
|
||||||
'provider_id' => 'free',
|
'provider_id' => 'free',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -146,8 +146,3 @@ class RegisteredUserController extends Controller
|
|||||||
return Inertia::location(route('verification.notice'));
|
return Inertia::location(route('verification.notice'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,22 +7,16 @@ use App\Models\AbandonedCheckout;
|
|||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
use App\Support\Concerns\PresentsPackages;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Stripe\PaymentIntent;
|
|
||||||
use Stripe\Stripe;
|
|
||||||
|
|
||||||
use App\Http\Controllers\PayPalController;
|
|
||||||
use App\Support\Concerns\PresentsPackages;
|
|
||||||
|
|
||||||
class CheckoutController extends Controller
|
class CheckoutController extends Controller
|
||||||
{
|
{
|
||||||
@@ -32,6 +26,7 @@ class CheckoutController extends Controller
|
|||||||
{
|
{
|
||||||
$googleStatus = session()->pull('checkout_google_status');
|
$googleStatus = session()->pull('checkout_google_status');
|
||||||
$googleError = session()->pull('checkout_google_error');
|
$googleError = session()->pull('checkout_google_error');
|
||||||
|
$googleProfile = session()->pull('checkout_google_profile');
|
||||||
|
|
||||||
$packageOptions = Package::orderBy('price')->get()
|
$packageOptions = Package::orderBy('price')->get()
|
||||||
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
||||||
@@ -41,8 +36,6 @@ class CheckoutController extends Controller
|
|||||||
return Inertia::render('marketing/CheckoutWizardPage', [
|
return Inertia::render('marketing/CheckoutWizardPage', [
|
||||||
'package' => $this->presentPackage($package),
|
'package' => $this->presentPackage($package),
|
||||||
'packageOptions' => $packageOptions,
|
'packageOptions' => $packageOptions,
|
||||||
'stripePublishableKey' => config('services.stripe.key'),
|
|
||||||
'paypalClientId' => config('services.paypal.client_id'),
|
|
||||||
'privacyHtml' => view('legal.datenschutz-partial')->render(),
|
'privacyHtml' => view('legal.datenschutz-partial')->render(),
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => Auth::user(),
|
'user' => Auth::user(),
|
||||||
@@ -50,6 +43,11 @@ class CheckoutController extends Controller
|
|||||||
'googleAuth' => [
|
'googleAuth' => [
|
||||||
'status' => $googleStatus,
|
'status' => $googleStatus,
|
||||||
'error' => $googleError,
|
'error' => $googleError,
|
||||||
|
'profile' => $googleProfile,
|
||||||
|
],
|
||||||
|
'paddle' => [
|
||||||
|
'environment' => config('paddle.environment'),
|
||||||
|
'client_token' => config('paddle.client_token'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -58,9 +56,16 @@ class CheckoutController extends Controller
|
|||||||
{
|
{
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'email' => 'required|email|unique:users,email',
|
'email' => 'required|email|unique:users,email',
|
||||||
|
'username' => 'required|string|max:255|unique:users,username',
|
||||||
'password' => ['required', 'confirmed', Password::defaults()],
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
'first_name' => 'required|string|max:255',
|
||||||
|
'last_name' => 'required|string|max:255',
|
||||||
|
'address' => 'required|string|max:500',
|
||||||
|
'phone' => 'required|string|max:255',
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'terms' => 'required|accepted',
|
'terms' => 'required|accepted',
|
||||||
|
'privacy_consent' => 'required|accepted',
|
||||||
|
'locale' => 'nullable|string|max:10',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
@@ -76,39 +81,46 @@ class CheckoutController extends Controller
|
|||||||
// User erstellen
|
// User erstellen
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'email' => $request->email,
|
'email' => $request->email,
|
||||||
|
'username' => $validated['username'],
|
||||||
|
'first_name' => $validated['first_name'],
|
||||||
|
'last_name' => $validated['last_name'],
|
||||||
|
'name' => trim($validated['first_name'].' '.$validated['last_name']),
|
||||||
|
'address' => $validated['address'],
|
||||||
|
'phone' => $validated['phone'],
|
||||||
|
'preferred_locale' => $validated['locale'] ?? null,
|
||||||
'password' => Hash::make($request->password),
|
'password' => Hash::make($request->password),
|
||||||
'pending_purchase' => true,
|
'pending_purchase' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Tenant erstellen
|
// Tenant erstellen
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'name' => $validated['first_name'] . ' ' . $validated['last_name'],
|
'name' => $validated['first_name'].' '.$validated['last_name'],
|
||||||
'slug' => Str::slug($validated['first_name'] . ' ' . $validated['last_name'] . '-' . now()->timestamp),
|
'slug' => Str::slug($validated['first_name'].' '.$validated['last_name'].'-'.now()->timestamp),
|
||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'is_suspended' => false,
|
'is_suspended' => false,
|
||||||
'event_credits_balance' => 0,
|
'event_credits_balance' => 0,
|
||||||
'subscription_tier' => 'free',
|
'subscription_tier' => 'free',
|
||||||
'subscription_expires_at' => null,
|
'subscription_expires_at' => null,
|
||||||
'settings' => json_encode([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#3B82F6',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#1F2937',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
'photo_likes_enabled' => false,
|
'photo_likes_enabled' => false,
|
||||||
'event_checklist' => false,
|
'event_checklist' => false,
|
||||||
'custom_domain' => false,
|
'custom_domain' => false,
|
||||||
'advanced_analytics' => false,
|
'advanced_analytics' => false,
|
||||||
],
|
],
|
||||||
'custom_domain' => null,
|
'custom_domain' => null,
|
||||||
'contact_email' => $validated['email'],
|
'contact_email' => $validated['email'],
|
||||||
'event_default_type' => 'general',
|
'event_default_type' => 'general',
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||||
// Package zuweisen
|
// Package zuweisen
|
||||||
@@ -151,12 +163,12 @@ class CheckoutController extends Controller
|
|||||||
// Custom Auth für Identifier (E-Mail oder Username)
|
// Custom Auth für Identifier (E-Mail oder Username)
|
||||||
$identifier = $request->identifier;
|
$identifier = $request->identifier;
|
||||||
$user = User::where('email', $identifier)
|
$user = User::where('email', $identifier)
|
||||||
->orWhere('username', $identifier)
|
->orWhere('username', $identifier)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']]
|
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']],
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +177,7 @@ class CheckoutController extends Controller
|
|||||||
|
|
||||||
// Checkout-spezifische Logik
|
// Checkout-spezifische Logik
|
||||||
DB::transaction(function () use ($request, $user, $packageId) {
|
DB::transaction(function () use ($request, $user, $packageId) {
|
||||||
if ($packageId && !$user->pending_purchase) {
|
if ($packageId && ! $user->pending_purchase) {
|
||||||
$user->update(['pending_purchase' => true]);
|
$user->update(['pending_purchase' => true]);
|
||||||
$request->session()->put('pending_package_id', $packageId);
|
$request->session()->put('pending_package_id', $packageId);
|
||||||
}
|
}
|
||||||
@@ -242,165 +254,6 @@ class CheckoutController extends Controller
|
|||||||
return response()->json(['status' => 'tracked']);
|
return response()->json(['status' => 'tracked']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createPaymentIntent(Request $request)
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'package_id' => 'required|exists:packages,id',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$package = Package::findOrFail($request->package_id);
|
|
||||||
|
|
||||||
\Log::info('Create Payment Intent', [
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'package_name' => $package->name,
|
|
||||||
'price' => $package->price,
|
|
||||||
'is_free' => $package->is_free,
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$isFreePackage = $this->packageIsFree($package);
|
|
||||||
|
|
||||||
if ($isFreePackage) {
|
|
||||||
\Log::info('Free package detected, returning null client_secret');
|
|
||||||
return response()->json([
|
|
||||||
'client_secret' => null,
|
|
||||||
'free_package' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stripe API Key setzen
|
|
||||||
Stripe::setApiKey(config('services.stripe.secret'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
$paymentIntent = PaymentIntent::create([
|
|
||||||
'amount' => $package->price * 100, // Stripe erwartet Cent
|
|
||||||
'currency' => 'eur',
|
|
||||||
'metadata' => [
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
\Log::info('PaymentIntent created successfully', [
|
|
||||||
'payment_intent_id' => $paymentIntent->id,
|
|
||||||
'client_secret' => substr($paymentIntent->client_secret, 0, 50) . '...',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'client_secret' => $paymentIntent->client_secret,
|
|
||||||
]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
\Log::error('Stripe PaymentIntent creation failed', [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'package_id' => $package->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'Fehler beim Erstellen der Zahlungsdaten: ' . $e->getMessage(),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirmPayment(Request $request)
|
|
||||||
{
|
|
||||||
$request->validate([
|
|
||||||
'payment_intent_id' => 'required|string',
|
|
||||||
'package_id' => 'required|exists:packages,id',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Stripe API Key setzen
|
|
||||||
Stripe::setApiKey(config('services.stripe.secret'));
|
|
||||||
|
|
||||||
$paymentIntent = PaymentIntent::retrieve($request->payment_intent_id);
|
|
||||||
|
|
||||||
if ($paymentIntent->status !== 'succeeded') {
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'Zahlung nicht erfolgreich.',
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$package = Package::findOrFail($request->package_id);
|
|
||||||
$user = Auth::user();
|
|
||||||
|
|
||||||
// Package dem Tenant zuweisen
|
|
||||||
$user->tenant->packages()->attach($package->id, [
|
|
||||||
'price' => $package->price,
|
|
||||||
'purchased_at' => now(),
|
|
||||||
'expires_at' => now()->addYear(),
|
|
||||||
'active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// pending_purchase zurücksetzen
|
|
||||||
$user->update(['pending_purchase' => false]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Zahlung erfolgreich bestätigt.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handlePayPalReturn(Request $request)
|
|
||||||
{
|
|
||||||
$orderId = $request->query('orderID');
|
|
||||||
|
|
||||||
if (!$orderId) {
|
|
||||||
return redirect('/checkout')->with('error', 'Ungültige PayPal-Rückkehr.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = Auth::user();
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
return redirect('/login')->with('error', 'Bitte melden Sie sich an.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Capture aufrufen
|
|
||||||
$paypalController = new PayPalController();
|
|
||||||
$captureRequest = new Request(['order_id' => $orderId]);
|
|
||||||
$captureResponse = $paypalController->captureOrder($captureRequest);
|
|
||||||
|
|
||||||
if ($captureResponse->getStatusCode() !== 200 || !isset($captureResponse->getData(true)['status']) || $captureResponse->getData(true)['status'] !== 'captured') {
|
|
||||||
Log::error('PayPal capture failed in return handler', ['order_id' => $orderId, 'response' => $captureResponse->getData(true)]);
|
|
||||||
return redirect('/checkout')->with('error', 'Zahlung konnte nicht abgeschlossen werden.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// PackagePurchase finden (erzeugt durch captureOrder)
|
|
||||||
$purchase = \App\Models\PackagePurchase::where('provider_id', $orderId)
|
|
||||||
->where('tenant_id', $user->tenant_id)
|
|
||||||
->latest()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (!$purchase) {
|
|
||||||
Log::error('No PackagePurchase found after PayPal capture', ['order_id' => $orderId, 'tenant_id' => $user->tenant_id]);
|
|
||||||
return redirect('/checkout')->with('error', 'Kauf konnte nicht verifiziert werden.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$package = \App\Models\Package::find($purchase->package_id);
|
|
||||||
|
|
||||||
if (!$package) {
|
|
||||||
return redirect('/checkout')->with('error', 'Paket nicht gefunden.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// TenantPackage zuweisen (ähnlich Stripe)
|
|
||||||
$user->tenant->packages()->attach($package->id, [
|
|
||||||
'price' => $package->price,
|
|
||||||
'purchased_at' => now(),
|
|
||||||
'expires_at' => now()->addYear(),
|
|
||||||
'active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// pending_purchase zurücksetzen
|
|
||||||
$user->update(['pending_purchase' => false]);
|
|
||||||
|
|
||||||
Log::info('PayPal payment completed and package assigned', ['order_id' => $orderId, 'package_id' => $package->id, 'tenant_id' => $user->tenant_id]);
|
|
||||||
|
|
||||||
return redirect('/success/' . $package->id)->with('success', 'Zahlung erfolgreich! Ihr Paket wurde aktiviert.');
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error in PayPal return handler', ['order_id' => $orderId, 'error' => $e->getMessage()]);
|
|
||||||
return redirect('/checkout')->with('error', 'Fehler beim Abschließen der Zahlung: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function packageIsFree(Package $package): bool
|
private function packageIsFree(Package $package): bool
|
||||||
{
|
{
|
||||||
if (isset($package->is_free)) {
|
if (isset($package->is_free)) {
|
||||||
|
|||||||
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Mail\Welcome;
|
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\Registered;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
@@ -52,56 +48,56 @@ class CheckoutGoogleController extends Controller
|
|||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('Google checkout login failed', ['message' => $e->getMessage()]);
|
Log::warning('Google checkout login failed', ['message' => $e->getMessage()]);
|
||||||
$this->flashError($request, __('checkout.google_error_fallback'));
|
$this->flashError($request, __('checkout.google_error_fallback'));
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId);
|
return $this->redirectBackToWizard($packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$email = $googleUser->getEmail();
|
$email = $googleUser->getEmail();
|
||||||
if (! $email) {
|
if (! $email) {
|
||||||
$this->flashError($request, __('checkout.google_missing_email'));
|
$this->flashError($request, __('checkout.google_missing_email'));
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId);
|
return $this->redirectBackToWizard($packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = DB::transaction(function () use ($googleUser, $email) {
|
$raw = $googleUser->getRaw();
|
||||||
$existing = User::where('email', $email)->first();
|
$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 = User::where('email', $email)->first();
|
||||||
$existing->forceFill([
|
|
||||||
'name' => $googleUser->getName() ?: $existing->name,
|
|
||||||
'pending_purchase' => true,
|
|
||||||
'email_verified_at' => $existing->email_verified_at ?? now(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
if (! $existing->tenant) {
|
if (! $existing) {
|
||||||
$this->createTenantForUser($existing, $googleUser->getName(), $email);
|
$request->session()->put('checkout_google_profile', array_filter([
|
||||||
}
|
|
||||||
|
|
||||||
return $existing->fresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::create([
|
|
||||||
'name' => $googleUser->getName(),
|
|
||||||
'email' => $email,
|
'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,
|
'pending_purchase' => true,
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => $existing->email_verified_at ?? now(),
|
||||||
]);
|
])->save();
|
||||||
|
|
||||||
event(new Registered($user));
|
if (! $existing->tenant) {
|
||||||
|
$this->createTenantForUser($existing, $googleUser->getName(), $email);
|
||||||
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
|
|
||||||
|
|
||||||
try {
|
|
||||||
Mail::to($user)
|
|
||||||
->locale($user->preferred_locale ?? app()->getLocale())
|
|
||||||
->queue(new Welcome($user));
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
Log::warning('Failed to queue welcome mail after Google signup', [
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tap($user)->setRelation('tenant', $tenant);
|
return $existing->fresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (! $user->tenant) {
|
if (! $user->tenant) {
|
||||||
@@ -111,7 +107,8 @@ class CheckoutGoogleController extends Controller
|
|||||||
Auth::login($user, true);
|
Auth::login($user, true);
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
$request->session()->forget(self::SESSION_KEY);
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
$request->session()->put('checkout_google_status', 'success');
|
$request->session()->forget('checkout_google_profile');
|
||||||
|
$request->session()->put('checkout_google_status', 'signin');
|
||||||
|
|
||||||
if ($packageId) {
|
if ($packageId) {
|
||||||
$this->ensurePackageAttached($user, (int) $packageId);
|
$this->ensurePackageAttached($user, (int) $packageId);
|
||||||
@@ -128,7 +125,7 @@ class CheckoutGoogleController extends Controller
|
|||||||
$counter = 1;
|
$counter = 1;
|
||||||
|
|
||||||
while (Tenant::where('slug', $slug)->exists()) {
|
while (Tenant::where('slug', $slug)->exists()) {
|
||||||
$slug = $slugBase . '-' . $counter;
|
$slug = $slugBase.'-'.$counter;
|
||||||
$counter++;
|
$counter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,42 +3,36 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Mail\ContactConfirmation;
|
use App\Mail\ContactConfirmation;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Stripe\Stripe;
|
|
||||||
use Stripe\StripeClient;
|
|
||||||
use Exception;
|
|
||||||
use PayPalHttp\Client;
|
|
||||||
use PayPalHttp\HttpException;
|
|
||||||
use PayPalCheckout\OrdersCreateRequest;
|
|
||||||
use PayPalCheckout\OrdersCaptureRequest;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\BlogPost;
|
use App\Models\BlogPost;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\TenantPackage;
|
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
|
use App\Models\TenantPackage;
|
||||||
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
|
use App\Support\Concerns\PresentsPackages;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use League\CommonMark\Environment\Environment;
|
use League\CommonMark\Environment\Environment;
|
||||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
|
||||||
use League\CommonMark\Extension\Table\TableExtension;
|
|
||||||
use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
||||||
|
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||||
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
||||||
|
use League\CommonMark\Extension\Table\TableExtension;
|
||||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||||
use League\CommonMark\MarkdownConverter;
|
use League\CommonMark\MarkdownConverter;
|
||||||
use App\Support\Concerns\PresentsPackages;
|
|
||||||
|
|
||||||
class MarketingController extends Controller
|
class MarketingController extends Controller
|
||||||
{
|
{
|
||||||
use PresentsPackages;
|
use PresentsPackages;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct(
|
||||||
{
|
private readonly CheckoutSessionService $checkoutSessions,
|
||||||
Stripe::setApiKey(config('services.stripe.key'));
|
private readonly PaddleCheckoutService $paddleCheckout,
|
||||||
}
|
) {}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
@@ -69,7 +63,7 @@ class MarketingController extends Controller
|
|||||||
'email' => $request->email,
|
'email' => $request->email,
|
||||||
'message' => $request->message,
|
'message' => $request->message,
|
||||||
], $locale),
|
], $locale),
|
||||||
function ($message) use ($request, $contactAddress, $locale) {
|
function ($message) use ($contactAddress, $locale) {
|
||||||
$message->to($contactAddress)
|
$message->to($contactAddress)
|
||||||
->subject(__('emails.contact.subject', [], $locale));
|
->subject(__('emails.contact.subject', [], $locale));
|
||||||
}
|
}
|
||||||
@@ -94,22 +88,22 @@ class MarketingController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function buyPackages(Request $request, $packageId)
|
public function buyPackages(Request $request, $packageId)
|
||||||
{
|
{
|
||||||
Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId, 'provider' => $request->input('provider', 'stripe')]);
|
Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId]);
|
||||||
$package = Package::findOrFail($packageId);
|
$package = Package::findOrFail($packageId);
|
||||||
|
|
||||||
if (!Auth::check()) {
|
if (! Auth::check()) {
|
||||||
return redirect()->route('register', ['package_id' => $package->id])
|
return redirect()->route('register', ['package_id' => $package->id])
|
||||||
->with('message', __('marketing.packages.register_required'));
|
->with('message', __('marketing.packages.register_required'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user->email_verified_at) {
|
if (! $user->email_verified_at) {
|
||||||
return redirect()->route('verification.notice')
|
return redirect()->route('verification.notice')
|
||||||
->with('message', __('auth.verification_required'));
|
->with('message', __('auth.verification_required'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $user->tenant;
|
$tenant = $user->tenant;
|
||||||
if (!$tenant) {
|
if (! $tenant) {
|
||||||
abort(500, 'Tenant not found');
|
abort(500, 'Tenant not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +124,7 @@ class MarketingController extends Controller
|
|||||||
PackagePurchase::create([
|
PackagePurchase::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
|
'provider' => 'free',
|
||||||
'provider_id' => 'free',
|
'provider_id' => 'free',
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
||||||
@@ -140,246 +135,49 @@ class MarketingController extends Controller
|
|||||||
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
|
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($package->type === 'reseller') {
|
if (! $package->paddle_price_id) {
|
||||||
return $this->stripeSubscription($request, $packageId);
|
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
|
||||||
|
|
||||||
|
return redirect()->route('packages', ['highlight' => $package->slug])
|
||||||
|
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->input('provider') === 'paypal') {
|
$session = $this->checkoutSessions->createOrResume($user, $package, [
|
||||||
return $this->paypalCheckout($request, $packageId);
|
'tenant' => $tenant,
|
||||||
}
|
]);
|
||||||
|
|
||||||
return $this->checkout($request, $packageId);
|
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||||
}
|
|
||||||
|
|
||||||
|
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||||
|
'success_url' => route('marketing.success', ['packageId' => $package->id]),
|
||||||
/**
|
'return_url' => route('packages', ['highlight' => $package->slug]),
|
||||||
* Checkout for Stripe with auth metadata.
|
|
||||||
*/
|
|
||||||
public function checkout(Request $request, $packageId)
|
|
||||||
{
|
|
||||||
$package = Package::findOrFail($packageId);
|
|
||||||
$user = Auth::user();
|
|
||||||
$tenant = $user->tenant;
|
|
||||||
|
|
||||||
$stripe = new StripeClient(config('services.stripe.secret'));
|
|
||||||
$session = $stripe->checkout->sessions->create([
|
|
||||||
'payment_method_types' => ['card'],
|
|
||||||
'line_items' => [[
|
|
||||||
'price_data' => [
|
|
||||||
'currency' => 'eur',
|
|
||||||
'product_data' => [
|
|
||||||
'name' => $package->name,
|
|
||||||
],
|
|
||||||
'unit_amount' => $package->price * 100,
|
|
||||||
],
|
|
||||||
'quantity' => 1,
|
|
||||||
]],
|
|
||||||
'mode' => 'payment',
|
|
||||||
'success_url' => route('marketing.success', $packageId),
|
|
||||||
'cancel_url' => route('packages'),
|
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'user_id' => $user->id,
|
'checkout_session_id' => $session->id,
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'type' => $package->type,
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Log::info('Stripe Checkout initiated', ['package_id' => $packageId, 'session_id' => $session->id, 'tenant_id' => $tenant->id]);
|
$session->forceFill([
|
||||||
|
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||||
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
|
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||||
|
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||||
|
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||||
|
])),
|
||||||
|
])->save();
|
||||||
|
|
||||||
return redirect($session->url, 303);
|
$redirectUrl = $checkout['checkout_url'] ?? null;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (! $redirectUrl) {
|
||||||
* PayPal checkout with v2 Orders API (one-time payment).
|
throw ValidationException::withMessages([
|
||||||
*/
|
'paddle' => __('marketing.packages.paddle_checkout_failed'),
|
||||||
public function paypalCheckout(Request $request, $packageId)
|
]);
|
||||||
{
|
|
||||||
$package = Package::findOrFail($packageId);
|
|
||||||
$user = Auth::user();
|
|
||||||
$tenant = $user->tenant;
|
|
||||||
|
|
||||||
$client = Client::create([
|
|
||||||
'clientId' => config('services.paypal.client_id'),
|
|
||||||
'clientSecret' => config('services.paypal.secret'),
|
|
||||||
'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$ordersController = $client->orders();
|
|
||||||
|
|
||||||
$metadata = json_encode([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'type' => $package->type,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$createRequest = new OrdersCreateRequest();
|
|
||||||
$createRequest->prefer('return=representation');
|
|
||||||
$createRequest->body = [
|
|
||||||
"intent" => "CAPTURE",
|
|
||||||
"purchase_units" => [[
|
|
||||||
"amount" => [
|
|
||||||
"currency_code" => "EUR",
|
|
||||||
"value" => number_format($package->price, 2, '.', ''),
|
|
||||||
],
|
|
||||||
"description" => "Package: " . $package->name,
|
|
||||||
"custom_id" => $metadata,
|
|
||||||
]],
|
|
||||||
"application_context" => [
|
|
||||||
"return_url" => route('marketing.success', $packageId),
|
|
||||||
"cancel_url" => route('packages'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = $ordersController->createOrder($createRequest);
|
|
||||||
$order = $response->result;
|
|
||||||
|
|
||||||
Log::info('PayPal Checkout initiated', ['package_id' => $packageId, 'order_id' => $order->id, 'tenant_id' => $tenant->id]);
|
|
||||||
|
|
||||||
session(['paypal_order_id' => $order->id]);
|
|
||||||
|
|
||||||
foreach ($order->links as $link) {
|
|
||||||
if ($link->rel === 'approve') {
|
|
||||||
return redirect($link->href);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Exception('No approve link found');
|
|
||||||
} 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
public function success(Request $request, $packageId = null)
|
||||||
{
|
{
|
||||||
$provider = session('paypal_order_id') ? 'paypal' : 'stripe';
|
|
||||||
Log::info('Payment Success: Provider processed', ['provider' => $provider, 'package_id' => $packageId]);
|
|
||||||
|
|
||||||
if (session('paypal_order_id')) {
|
|
||||||
$orderId = session('paypal_order_id');
|
|
||||||
$client = Client::create([
|
|
||||||
'clientId' => config('services.paypal.client_id'),
|
|
||||||
'clientSecret' => config('services.paypal.secret'),
|
|
||||||
'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$ordersController = $client->orders();
|
|
||||||
|
|
||||||
$captureRequest = new OrdersCaptureRequest($orderId);
|
|
||||||
$captureRequest->prefer('return=minimal');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$captureResponse = $ordersController->captureOrder($captureRequest);
|
|
||||||
$capture = $captureResponse->result;
|
|
||||||
|
|
||||||
Log::info('PayPal Capture completed', ['order_id' => $orderId, 'status' => $capture->status]);
|
|
||||||
|
|
||||||
if ($capture->status === 'COMPLETED') {
|
|
||||||
$customId = $capture->purchaseUnits[0]->customId ?? null;
|
|
||||||
if ($customId) {
|
|
||||||
$metadata = json_decode($customId, true);
|
|
||||||
$package = Package::find($metadata['package_id']);
|
|
||||||
$tenant = Tenant::find($metadata['tenant_id']);
|
|
||||||
|
|
||||||
if ($package && $tenant) {
|
|
||||||
TenantPackage::updateOrCreate(
|
|
||||||
[
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'price' => $package->price,
|
|
||||||
'active' => true,
|
|
||||||
'purchased_at' => now(),
|
|
||||||
'expires_at' => now()->addYear(), // One-time as annual for reseller too
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
PackagePurchase::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'provider_id' => 'paypal',
|
|
||||||
'price' => $package->price,
|
|
||||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
|
||||||
'purchased_at' => now(),
|
|
||||||
'refunded' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->forget('paypal_order_id');
|
|
||||||
$request->session()->flash('success', __('marketing.packages.purchased_successfully', ['name' => $package->name]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log::error('PayPal capture failed: ' . $capture->status);
|
|
||||||
$request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.');
|
|
||||||
}
|
|
||||||
} catch (HttpException $e) {
|
|
||||||
Log::error('PayPal capture error: ' . $e->getMessage());
|
|
||||||
$request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.');
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('PayPal success error: ' . $e->getMessage());
|
|
||||||
$request->session()->flash('error', 'Fehler beim Abschliessen der Zahlung.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common logic: Redirect to admin if verified
|
|
||||||
if (Auth::check() && Auth::user()->email_verified_at) {
|
if (Auth::check() && Auth::user()->email_verified_at) {
|
||||||
return redirect('/event-admin')->with('success', __('marketing.success.welcome'));
|
return redirect('/event-admin')->with('success', __('marketing.success.welcome'));
|
||||||
}
|
}
|
||||||
@@ -392,7 +190,7 @@ class MarketingController extends Controller
|
|||||||
$locale = $request->get('locale', app()->getLocale());
|
$locale = $request->get('locale', app()->getLocale());
|
||||||
Log::info('Blog Index Debug - Initial', [
|
Log::info('Blog Index Debug - Initial', [
|
||||||
'locale' => $locale,
|
'locale' => $locale,
|
||||||
'full_url' => $request->fullUrl()
|
'full_url' => $request->fullUrl(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$query = BlogPost::query()
|
$query = BlogPost::query()
|
||||||
@@ -424,6 +222,7 @@ class MarketingController extends Controller
|
|||||||
$post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '';
|
$post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '';
|
||||||
$post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '';
|
$post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '';
|
||||||
$post->content = $post->getTranslation('content', $locale) ?? $post->getTranslation('content', 'de') ?? '';
|
$post->content = $post->getTranslation('content', $locale) ?? $post->getTranslation('content', 'de') ?? '';
|
||||||
|
|
||||||
// Author name is a string, no translation needed; author is loaded via with('author')
|
// Author name is a string, no translation needed; author is loaded via with('author')
|
||||||
return $post;
|
return $post;
|
||||||
});
|
});
|
||||||
@@ -432,7 +231,7 @@ class MarketingController extends Controller
|
|||||||
'count' => $posts->count(),
|
'count' => $posts->count(),
|
||||||
'total' => $posts->total(),
|
'total' => $posts->total(),
|
||||||
'posts_data' => $posts->toArray(),
|
'posts_data' => $posts->toArray(),
|
||||||
'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts'
|
'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Inertia::render('marketing/Blog', compact('posts'));
|
return Inertia::render('marketing/Blog', compact('posts'));
|
||||||
@@ -456,12 +255,12 @@ class MarketingController extends Controller
|
|||||||
// Transform to array with translated strings for the current locale
|
// Transform to array with translated strings for the current locale
|
||||||
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
|
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
|
||||||
|
|
||||||
$environment = new Environment();
|
$environment = new Environment;
|
||||||
$environment->addExtension(new CommonMarkCoreExtension());
|
$environment->addExtension(new CommonMarkCoreExtension);
|
||||||
$environment->addExtension(new TableExtension());
|
$environment->addExtension(new TableExtension);
|
||||||
$environment->addExtension(new AutolinkExtension());
|
$environment->addExtension(new AutolinkExtension);
|
||||||
$environment->addExtension(new StrikethroughExtension());
|
$environment->addExtension(new StrikethroughExtension);
|
||||||
$environment->addExtension(new TaskListExtension());
|
$environment->addExtension(new TaskListExtension);
|
||||||
|
|
||||||
$converter = new MarkdownConverter($environment);
|
$converter = new MarkdownConverter($environment);
|
||||||
$contentHtml = (string) $converter->convert($markdown);
|
$contentHtml = (string) $converter->convert($markdown);
|
||||||
@@ -471,7 +270,7 @@ class MarketingController extends Controller
|
|||||||
'type' => gettype($contentHtml),
|
'type' => gettype($contentHtml),
|
||||||
'is_string' => is_string($contentHtml),
|
'is_string' => is_string($contentHtml),
|
||||||
'length' => strlen($contentHtml ?? ''),
|
'length' => strlen($contentHtml ?? ''),
|
||||||
'preview' => substr((string)$contentHtml, 0, 200) . '...'
|
'preview' => substr((string) $contentHtml, 0, 200).'...',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$post = [
|
$post = [
|
||||||
@@ -484,7 +283,7 @@ class MarketingController extends Controller
|
|||||||
'published_at' => $postModel->published_at->toDateString(),
|
'published_at' => $postModel->published_at->toDateString(),
|
||||||
'slug' => $postModel->slug,
|
'slug' => $postModel->slug,
|
||||||
'author' => $postModel->author ? [
|
'author' => $postModel->author ? [
|
||||||
'name' => $postModel->author->name
|
'name' => $postModel->author->name,
|
||||||
] : null,
|
] : null,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -527,11 +326,11 @@ class MarketingController extends Controller
|
|||||||
'locale' => app()->getLocale(),
|
'locale' => app()->getLocale(),
|
||||||
'url' => request()->fullUrl(),
|
'url' => request()->fullUrl(),
|
||||||
'route' => request()->route()->getName(),
|
'route' => request()->route()->getName(),
|
||||||
'isInertia' => request()->header('X-Inertia')
|
'isInertia' => request()->header('X-Inertia'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$validTypes = ['hochzeit', 'geburtstag', 'firmenevent'];
|
$validTypes = ['hochzeit', 'geburtstag', 'firmenevent'];
|
||||||
if (!in_array($type, $validTypes)) {
|
if (! in_array($type, $validTypes)) {
|
||||||
Log::warning('Invalid occasion type accessed', ['type' => $type]);
|
Log::warning('Invalid occasion type accessed', ['type' => $type]);
|
||||||
abort(404, 'Invalid occasion type');
|
abort(404, 'Invalid occasion type');
|
||||||
}
|
}
|
||||||
|
|||||||
97
app/Http/Controllers/PaddleCheckoutController.php
Normal file
97
app/Http/Controllers/PaddleCheckoutController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Controllers/PaddleWebhookController.php
Normal file
68
app/Http/Controllers/PaddleWebhookController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,8 @@ class ContentSecurityPolicy
|
|||||||
"'nonce-{$scriptNonce}'",
|
"'nonce-{$scriptNonce}'",
|
||||||
'https://js.stripe.com',
|
'https://js.stripe.com',
|
||||||
'https://js.stripe.network',
|
'https://js.stripe.network',
|
||||||
|
'https://cdn.paddle.com',
|
||||||
|
'https://global.localizecdn.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
$styleSources = [
|
$styleSources = [
|
||||||
@@ -51,11 +53,22 @@ class ContentSecurityPolicy
|
|||||||
"'self'",
|
"'self'",
|
||||||
'https://api.stripe.com',
|
'https://api.stripe.com',
|
||||||
'https://api.stripe.network',
|
'https://api.stripe.network',
|
||||||
|
'https://api.paddle.com',
|
||||||
|
'https://sandbox-api.paddle.com',
|
||||||
|
'https://checkout.paddle.com',
|
||||||
|
'https://sandbox-checkout.paddle.com',
|
||||||
|
'https://checkout-service.paddle.com',
|
||||||
|
'https://sandbox-checkout-service.paddle.com',
|
||||||
|
'https://global.localizecdn.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
$frameSources = [
|
$frameSources = [
|
||||||
"'self'",
|
"'self'",
|
||||||
'https://js.stripe.com',
|
'https://js.stripe.com',
|
||||||
|
'https://checkout.paddle.com',
|
||||||
|
'https://sandbox-checkout.paddle.com',
|
||||||
|
'https://checkout-service.paddle.com',
|
||||||
|
'https://sandbox-checkout-service.paddle.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
$imgSources = [
|
$imgSources = [
|
||||||
|
|||||||
78
app/Jobs/PullPackageFromPaddle.php
Normal file
78
app/Jobs/PullPackageFromPaddle.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/Jobs/SyncPackageToPaddle.php
Normal file
138
app/Jobs/SyncPackageToPaddle.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,16 +15,25 @@ class CheckoutSession extends Model
|
|||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
public const STATUS_DRAFT = 'draft';
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
public const STATUS_AWAITING_METHOD = 'awaiting_payment_method';
|
public const STATUS_AWAITING_METHOD = 'awaiting_payment_method';
|
||||||
|
|
||||||
public const STATUS_REQUIRES_CUSTOMER_ACTION = 'requires_customer_action';
|
public const STATUS_REQUIRES_CUSTOMER_ACTION = 'requires_customer_action';
|
||||||
|
|
||||||
public const STATUS_PROCESSING = 'processing';
|
public const STATUS_PROCESSING = 'processing';
|
||||||
|
|
||||||
public const STATUS_COMPLETED = 'completed';
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
public const STATUS_FAILED = 'failed';
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
public const STATUS_CANCELLED = 'cancelled';
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
public const PROVIDER_NONE = 'none';
|
public const PROVIDER_NONE = 'none';
|
||||||
|
|
||||||
public const PROVIDER_STRIPE = 'stripe';
|
public const PROVIDER_STRIPE = 'stripe';
|
||||||
public const PROVIDER_PAYPAL = 'paypal';
|
|
||||||
|
public const PROVIDER_PADDLE = 'paddle';
|
||||||
|
|
||||||
public const PROVIDER_FREE = 'free';
|
public const PROVIDER_FREE = 'free';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
||||||
|
|
||||||
class Package extends Model
|
class Package extends Model
|
||||||
{
|
{
|
||||||
@@ -29,6 +29,11 @@ class Package extends Model
|
|||||||
'description',
|
'description',
|
||||||
'description_translations',
|
'description_translations',
|
||||||
'description_table',
|
'description_table',
|
||||||
|
'paddle_product_id',
|
||||||
|
'paddle_price_id',
|
||||||
|
'paddle_sync_status',
|
||||||
|
'paddle_synced_at',
|
||||||
|
'paddle_snapshot',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -45,9 +50,10 @@ class Package extends Model
|
|||||||
'name_translations' => 'array',
|
'name_translations' => 'array',
|
||||||
'description_translations' => 'array',
|
'description_translations' => 'array',
|
||||||
'description_table' => 'array',
|
'description_table' => 'array',
|
||||||
|
'paddle_synced_at' => 'datetime',
|
||||||
|
'paddle_snapshot' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
protected function features(): Attribute
|
protected function features(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
@@ -73,7 +79,6 @@ class Package extends Model
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function eventPackages(): HasMany
|
public function eventPackages(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(EventPackage::class);
|
return $this->hasMany(EventPackage::class);
|
||||||
@@ -104,12 +109,12 @@ class Package extends Model
|
|||||||
$locale = $locale ?: app()->getLocale();
|
$locale = $locale ?: app()->getLocale();
|
||||||
$translations = $this->name_translations ?? [];
|
$translations = $this->name_translations ?? [];
|
||||||
|
|
||||||
if (!empty($translations[$locale])) {
|
if (! empty($translations[$locale])) {
|
||||||
return $translations[$locale];
|
return $translations[$locale];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['en', 'de'] as $fallback) {
|
foreach (['en', 'de'] as $fallback) {
|
||||||
if ($locale !== $fallback && !empty($translations[$fallback])) {
|
if ($locale !== $fallback && ! empty($translations[$fallback])) {
|
||||||
return $translations[$fallback];
|
return $translations[$fallback];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class PackagePurchase extends Model
|
|||||||
'tenant_id',
|
'tenant_id',
|
||||||
'event_id',
|
'event_id',
|
||||||
'package_id',
|
'package_id',
|
||||||
|
'provider',
|
||||||
'provider_id',
|
'provider_id',
|
||||||
'price',
|
'price',
|
||||||
'type',
|
'type',
|
||||||
@@ -78,11 +79,11 @@ class PackagePurchase extends Model
|
|||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
static::creating(function ($purchase) {
|
static::creating(function ($purchase) {
|
||||||
if (!$purchase->tenant_id) {
|
if (! $purchase->tenant_id) {
|
||||||
throw new \Exception('Tenant ID is required for package purchases.');
|
throw new \Exception('Tenant ID is required for package purchases.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$purchase->purchased_at) {
|
if (! $purchase->purchased_at) {
|
||||||
$purchase->purchased_at = now();
|
$purchase->purchased_at = now();
|
||||||
}
|
}
|
||||||
$purchase->refunded = false;
|
$purchase->refunded = false;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Carbon\Carbon;
|
|
||||||
|
|
||||||
class TenantPackage extends Model
|
class TenantPackage extends Model
|
||||||
{
|
{
|
||||||
@@ -16,6 +15,7 @@ class TenantPackage extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'tenant_id',
|
'tenant_id',
|
||||||
'package_id',
|
'package_id',
|
||||||
|
'paddle_subscription_id',
|
||||||
'price',
|
'price',
|
||||||
'purchased_at',
|
'purchased_at',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
@@ -43,30 +43,32 @@ class TenantPackage extends Model
|
|||||||
|
|
||||||
public function isActive(): bool
|
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
|
public function canCreateEvent(): bool
|
||||||
{
|
{
|
||||||
if (!$this->isActive()) {
|
if (! $this->isActive()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->package->isReseller()) {
|
if (! $this->package->isReseller()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$maxEvents = $this->package->max_events_per_year ?? 0;
|
$maxEvents = $this->package->max_events_per_year ?? 0;
|
||||||
|
|
||||||
return $this->used_events < $maxEvents;
|
return $this->used_events < $maxEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRemainingEventsAttribute(): int
|
public function getRemainingEventsAttribute(): int
|
||||||
{
|
{
|
||||||
if (!$this->package->isReseller()) {
|
if (! $this->package->isReseller()) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$max = $this->package->max_events_per_year ?? 0;
|
$max = $this->package->max_events_per_year ?? 0;
|
||||||
|
|
||||||
return max(0, $max - $this->used_events);
|
return max(0, $max - $this->used_events);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,17 +77,21 @@ class TenantPackage extends Model
|
|||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
static::creating(function ($tenantPackage) {
|
static::creating(function ($tenantPackage) {
|
||||||
if (!$tenantPackage->purchased_at) {
|
if (! $tenantPackage->purchased_at) {
|
||||||
$tenantPackage->purchased_at = now();
|
$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->expires_at = now()->addYear(); // Standard für Reseller
|
||||||
}
|
}
|
||||||
$tenantPackage->active = true;
|
$tenantPackage->active = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
static::updating(function ($tenantPackage) {
|
static::updating(function ($tenantPackage) {
|
||||||
if ($tenantPackage->isDirty('expires_at') && $tenantPackage->expires_at->isPast()) {
|
if (
|
||||||
|
$tenantPackage->isDirty('expires_at')
|
||||||
|
&& $tenantPackage->expires_at instanceof \Carbon\CarbonInterface
|
||||||
|
&& $tenantPackage->expires_at->isPast()
|
||||||
|
) {
|
||||||
$tenantPackage->active = false;
|
$tenantPackage->active = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ namespace App\Services\Checkout;
|
|||||||
|
|
||||||
use App\Mail\PurchaseConfirmation;
|
use App\Mail\PurchaseConfirmation;
|
||||||
use App\Mail\Welcome;
|
use App\Mail\Welcome;
|
||||||
use App\Models\CheckoutSession;
|
|
||||||
use App\Models\AbandonedCheckout;
|
use App\Models\AbandonedCheckout;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@@ -47,10 +47,19 @@ class CheckoutAssignmentService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$metadata = $session->provider_metadata ?? [];
|
||||||
|
|
||||||
$providerReference = $options['provider_reference']
|
$providerReference = $options['provider_reference']
|
||||||
|
?? $metadata['paddle_transaction_id'] ?? null
|
||||||
|
?? $metadata['paddle_checkout_id'] ?? null
|
||||||
?? $session->stripe_payment_intent_id
|
?? $session->stripe_payment_intent_id
|
||||||
?? $session->paypal_order_id
|
?? CheckoutSession::PROVIDER_FREE;
|
||||||
?? 'free';
|
|
||||||
|
$providerName = $options['provider']
|
||||||
|
?? $session->provider
|
||||||
|
?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null)
|
||||||
|
?? ($session->stripe_payment_intent_id ? CheckoutSession::PROVIDER_STRIPE : null)
|
||||||
|
?? CheckoutSession::PROVIDER_FREE;
|
||||||
|
|
||||||
$purchase = PackagePurchase::updateOrCreate(
|
$purchase = PackagePurchase::updateOrCreate(
|
||||||
[
|
[
|
||||||
@@ -59,6 +68,7 @@ class CheckoutAssignmentService
|
|||||||
'provider_id' => $providerReference,
|
'provider_id' => $providerReference,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
'provider' => $providerName,
|
||||||
'price' => $session->amount_total,
|
'price' => $session->amount_total,
|
||||||
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
|
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
@@ -121,6 +131,7 @@ class CheckoutAssignmentService
|
|||||||
if (! $user->tenant_id) {
|
if (! $user->tenant_id) {
|
||||||
$user->forceFill(['tenant_id' => $user->tenant->getKey()])->save();
|
$user->forceFill(['tenant_id' => $user->tenant->getKey()])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->tenant;
|
return $user->tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ class CheckoutPaymentService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutSessionService $sessions,
|
private readonly CheckoutSessionService $sessions,
|
||||||
private readonly CheckoutAssignmentService $assignment,
|
private readonly CheckoutAssignmentService $assignment,
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function initialiseStripe(CheckoutSession $session, array $payload = []): array
|
public function initialiseStripe(CheckoutSession $session, array $payload = []): array
|
||||||
{
|
{
|
||||||
@@ -40,32 +39,6 @@ class CheckoutPaymentService
|
|||||||
return $session;
|
return $session;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function initialisePayPal(CheckoutSession $session, array $payload = []): array
|
|
||||||
{
|
|
||||||
if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) {
|
|
||||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: integrate PayPal Orders API and return order id + approval link
|
|
||||||
return [
|
|
||||||
'session_id' => $session->id,
|
|
||||||
'status' => $session->status,
|
|
||||||
'message' => 'PayPal integration pending implementation.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function capturePayPal(CheckoutSession $session, array $payload = []): CheckoutSession
|
|
||||||
{
|
|
||||||
if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) {
|
|
||||||
throw new LogicException('Cannot capture PayPal payment on a non-PayPal session.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: call PayPal capture endpoint and persist order/subscription identifiers
|
|
||||||
$this->sessions->markProcessing($session);
|
|
||||||
|
|
||||||
return $session;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function finaliseFree(CheckoutSession $session): CheckoutSession
|
public function finaliseFree(CheckoutSession $session): CheckoutSession
|
||||||
{
|
{
|
||||||
if ($session->provider !== CheckoutSession::PROVIDER_FREE) {
|
if ($session->provider !== CheckoutSession::PROVIDER_FREE) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class CheckoutSessionService
|
|||||||
return $existing;
|
return $existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = new CheckoutSession();
|
$session = new CheckoutSession;
|
||||||
$session->id = (string) Str::uuid();
|
$session->id = (string) Str::uuid();
|
||||||
$session->status = CheckoutSession::STATUS_DRAFT;
|
$session->status = CheckoutSession::STATUS_DRAFT;
|
||||||
$session->provider = CheckoutSession::PROVIDER_NONE;
|
$session->provider = CheckoutSession::PROVIDER_NONE;
|
||||||
@@ -69,8 +69,8 @@ class CheckoutSessionService
|
|||||||
$session->stripe_payment_intent_id = null;
|
$session->stripe_payment_intent_id = null;
|
||||||
$session->stripe_customer_id = null;
|
$session->stripe_customer_id = null;
|
||||||
$session->stripe_subscription_id = null;
|
$session->stripe_subscription_id = null;
|
||||||
$session->paypal_order_id = null;
|
$session->paddle_checkout_id = null;
|
||||||
$session->paypal_subscription_id = null;
|
$session->paddle_transaction_id = null;
|
||||||
$session->provider_metadata = [];
|
$session->provider_metadata = [];
|
||||||
$session->failure_reason = null;
|
$session->failure_reason = null;
|
||||||
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
|
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
|
||||||
@@ -85,7 +85,11 @@ class CheckoutSessionService
|
|||||||
{
|
{
|
||||||
$provider = strtolower($provider);
|
$provider = strtolower($provider);
|
||||||
|
|
||||||
if (! in_array($provider, [CheckoutSession::PROVIDER_STRIPE, CheckoutSession::PROVIDER_PAYPAL, CheckoutSession::PROVIDER_FREE], true)) {
|
if (! in_array($provider, [
|
||||||
|
CheckoutSession::PROVIDER_STRIPE,
|
||||||
|
CheckoutSession::PROVIDER_PADDLE,
|
||||||
|
CheckoutSession::PROVIDER_FREE,
|
||||||
|
], true)) {
|
||||||
throw new RuntimeException("Unsupported checkout provider [{$provider}]");
|
throw new RuntimeException("Unsupported checkout provider [{$provider}]");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ class CheckoutSessionService
|
|||||||
return $session;
|
return $session;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function markRequiresCustomerAction(CheckoutSession $session, string $reason = null): CheckoutSession
|
public function markRequiresCustomerAction(CheckoutSession $session, ?string $reason = null): CheckoutSession
|
||||||
{
|
{
|
||||||
$session->status = CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION;
|
$session->status = CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION;
|
||||||
$session->failure_reason = $reason;
|
$session->failure_reason = $reason;
|
||||||
|
|||||||
@@ -3,17 +3,23 @@
|
|||||||
namespace App\Services\Checkout;
|
namespace App\Services\Checkout;
|
||||||
|
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
|
use App\Models\Package;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantPackage;
|
||||||
|
use App\Services\Paddle\PaddleSubscriptionService;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class CheckoutWebhookService
|
class CheckoutWebhookService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutSessionService $sessions,
|
private readonly CheckoutSessionService $sessions,
|
||||||
private readonly CheckoutAssignmentService $assignment,
|
private readonly CheckoutAssignmentService $assignment,
|
||||||
) {
|
private readonly PaddleSubscriptionService $paddleSubscriptions,
|
||||||
}
|
) {}
|
||||||
|
|
||||||
public function handleStripeEvent(array $event): bool
|
public function handleStripeEvent(array $event): bool
|
||||||
{
|
{
|
||||||
@@ -72,29 +78,37 @@ class CheckoutWebhookService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handlePayPalEvent(array $event): bool
|
public function handlePaddleEvent(array $event): bool
|
||||||
{
|
{
|
||||||
$eventType = $event['event_type'] ?? null;
|
$eventType = $event['event_type'] ?? null;
|
||||||
$resource = $event['resource'] ?? [];
|
$data = $event['data'] ?? [];
|
||||||
|
|
||||||
if (! $eventType || ! is_array($resource)) {
|
if (! $eventType || ! is_array($data)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$orderId = $resource['order_id'] ?? $resource['id'] ?? null;
|
if (Str::startsWith($eventType, 'subscription.')) {
|
||||||
|
return $this->handlePaddleSubscriptionEvent($eventType, $data);
|
||||||
|
}
|
||||||
|
|
||||||
$session = $this->locatePayPalSession($resource, $orderId);
|
$session = $this->locatePaddleSession($data);
|
||||||
|
|
||||||
if (! $session) {
|
if (! $session) {
|
||||||
|
Log::info('[CheckoutWebhook] Paddle session not resolved', [
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'transaction_id' => $data['id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lockKey = "checkout:webhook:paypal:".($orderId ?: $session->id);
|
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
|
||||||
|
$lockKey = 'checkout:webhook:paddle:'.($transactionId ?: $session->id);
|
||||||
$lock = Cache::lock($lockKey, 30);
|
$lock = Cache::lock($lockKey, 30);
|
||||||
|
|
||||||
if (! $lock->get()) {
|
if (! $lock->get()) {
|
||||||
Log::info('[CheckoutWebhook] PayPal lock busy', [
|
Log::info('[CheckoutWebhook] Paddle lock busy', [
|
||||||
'order_id' => $orderId,
|
'transaction_id' => $transactionId,
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -102,22 +116,29 @@ class CheckoutWebhookService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$session->forceFill([
|
if ($transactionId) {
|
||||||
'paypal_order_id' => $orderId ?: $session->paypal_order_id,
|
$session->forceFill([
|
||||||
'provider' => CheckoutSession::PROVIDER_PAYPAL,
|
'paddle_transaction_id' => $transactionId,
|
||||||
])->save();
|
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||||
|
])->save();
|
||||||
|
} elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
||||||
|
$session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->save();
|
||||||
|
}
|
||||||
|
|
||||||
$metadata = [
|
$metadata = [
|
||||||
'paypal_last_event' => $eventType,
|
'paddle_last_event' => $eventType,
|
||||||
'paypal_last_event_id' => $event['id'] ?? null,
|
'paddle_transaction_id' => $transactionId,
|
||||||
'paypal_last_update_at' => now()->toIso8601String(),
|
'paddle_status' => $data['status'] ?? null,
|
||||||
'paypal_order_id' => $orderId,
|
'paddle_last_update_at' => now()->toIso8601String(),
|
||||||
'paypal_capture_id' => $resource['id'] ?? null,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (! empty($data['checkout_id'])) {
|
||||||
|
$metadata['paddle_checkout_id'] = $data['checkout_id'];
|
||||||
|
}
|
||||||
|
|
||||||
$this->mergeProviderMetadata($session, $metadata);
|
$this->mergeProviderMetadata($session, $metadata);
|
||||||
|
|
||||||
return $this->applyPayPalEvent($session, $eventType, $resource);
|
return $this->applyPaddleEvent($session, $eventType, $data);
|
||||||
} finally {
|
} finally {
|
||||||
$lock->release();
|
$lock->release();
|
||||||
}
|
}
|
||||||
@@ -131,16 +152,19 @@ class CheckoutWebhookService
|
|||||||
$this->sessions->markProcessing($session, [
|
$this->sessions->markProcessing($session, [
|
||||||
'stripe_intent_status' => $intent['status'] ?? null,
|
'stripe_intent_status' => $intent['status'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'payment_intent.requires_action':
|
case 'payment_intent.requires_action':
|
||||||
$reason = $intent['next_action']['type'] ?? 'requires_action';
|
$reason = $intent['next_action']['type'] ?? 'requires_action';
|
||||||
$this->sessions->markRequiresCustomerAction($session, $reason);
|
$this->sessions->markRequiresCustomerAction($session, $reason);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'payment_intent.payment_failed':
|
case 'payment_intent.payment_failed':
|
||||||
$failure = $intent['last_payment_error']['message'] ?? 'payment_failed';
|
$failure = $intent['last_payment_error']['message'] ?? 'payment_failed';
|
||||||
$this->sessions->markFailed($session, $failure);
|
$this->sessions->markFailed($session, $failure);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'payment_intent.succeeded':
|
case 'payment_intent.succeeded':
|
||||||
@@ -165,25 +189,30 @@ class CheckoutWebhookService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function applyPayPalEvent(CheckoutSession $session, string $eventType, array $resource): bool
|
protected function applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool
|
||||||
{
|
{
|
||||||
|
$status = strtolower((string) ($data['status'] ?? ''));
|
||||||
|
|
||||||
switch ($eventType) {
|
switch ($eventType) {
|
||||||
case 'CHECKOUT.ORDER.APPROVED':
|
case 'transaction.created':
|
||||||
|
case 'transaction.processing':
|
||||||
$this->sessions->markProcessing($session, [
|
$this->sessions->markProcessing($session, [
|
||||||
'paypal_order_status' => $resource['status'] ?? null,
|
'paddle_status' => $status ?: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'PAYMENT.CAPTURE.COMPLETED':
|
case 'transaction.completed':
|
||||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||||
$this->sessions->markProcessing($session, [
|
$this->sessions->markProcessing($session, [
|
||||||
'paypal_order_status' => $resource['status'] ?? null,
|
'paddle_status' => $status ?: 'completed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assignment->finalise($session, [
|
$this->assignment->finalise($session, [
|
||||||
'source' => 'paypal_webhook',
|
'source' => 'paddle_webhook',
|
||||||
'paypal_order_id' => $resource['order_id'] ?? null,
|
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||||
'paypal_capture_id' => $resource['id'] ?? null,
|
'provider_reference' => $data['id'] ?? null,
|
||||||
|
'payload' => $data,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->sessions->markCompleted($session, now());
|
$this->sessions->markCompleted($session, now());
|
||||||
@@ -191,8 +220,11 @@ class CheckoutWebhookService
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'PAYMENT.CAPTURE.DENIED':
|
case 'transaction.failed':
|
||||||
$this->sessions->markFailed($session, 'paypal_capture_denied');
|
case 'transaction.cancelled':
|
||||||
|
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
|
||||||
|
$this->sessions->markFailed($session, $reason);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -200,6 +232,169 @@ class CheckoutWebhookService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool
|
||||||
|
{
|
||||||
|
$subscriptionId = $data['id'] ?? null;
|
||||||
|
|
||||||
|
if (! $subscriptionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = $data['metadata'] ?? [];
|
||||||
|
$tenant = $this->resolveTenantFromSubscription($data, $metadata, $subscriptionId);
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [
|
||||||
|
'subscription_id' => $subscriptionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = $this->resolvePackageFromSubscription($data, $metadata, $subscriptionId);
|
||||||
|
|
||||||
|
if (! $package) {
|
||||||
|
Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [
|
||||||
|
'subscription_id' => $subscriptionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = strtolower((string) ($data['status'] ?? ''));
|
||||||
|
$expiresAt = $this->resolveSubscriptionExpiry($data);
|
||||||
|
$startedAt = $this->resolveSubscriptionStart($data);
|
||||||
|
|
||||||
|
$tenantPackage = TenantPackage::firstOrNew([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantPackage->fill([
|
||||||
|
'paddle_subscription_id' => $subscriptionId,
|
||||||
|
'price' => $package->price,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantPackage->expires_at = $expiresAt ?? $tenantPackage->expires_at ?? $startedAt?->copy()->addYear();
|
||||||
|
$tenantPackage->purchased_at = $tenantPackage->purchased_at
|
||||||
|
?? $tenant->purchases()->where('package_id', $package->id)->latest('purchased_at')->value('purchased_at')
|
||||||
|
?? $startedAt;
|
||||||
|
|
||||||
|
$tenantPackage->active = $this->isSubscriptionActive($status);
|
||||||
|
$tenantPackage->save();
|
||||||
|
|
||||||
|
if ($eventType === 'subscription.cancelled' || $eventType === 'subscription.paused') {
|
||||||
|
$tenantPackage->forceFill(['active' => false])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->forceFill([
|
||||||
|
'subscription_status' => $this->mapSubscriptionStatus($status),
|
||||||
|
'subscription_expires_at' => $expiresAt,
|
||||||
|
'paddle_customer_id' => $tenant->paddle_customer_id ?: ($data['customer_id'] ?? null),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
Log::info('[CheckoutWebhook] Paddle subscription event processed', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'subscription_id' => $subscriptionId,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveTenantFromSubscription(array $data, array $metadata, string $subscriptionId): ?Tenant
|
||||||
|
{
|
||||||
|
if (isset($metadata['tenant_id'])) {
|
||||||
|
$tenant = Tenant::find((int) $metadata['tenant_id']);
|
||||||
|
if ($tenant) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$customerId = $data['customer_id'] ?? null;
|
||||||
|
|
||||||
|
if ($customerId) {
|
||||||
|
$tenant = Tenant::where('paddle_customer_id', $customerId)->first();
|
||||||
|
if ($tenant) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
|
||||||
|
$customerId = Arr::get($subscription, 'data.customer_id');
|
||||||
|
|
||||||
|
if ($customerId) {
|
||||||
|
return Tenant::where('paddle_customer_id', $customerId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolvePackageFromSubscription(array $data, array $metadata, string $subscriptionId): ?Package
|
||||||
|
{
|
||||||
|
if (isset($metadata['package_id'])) {
|
||||||
|
$package = Package::find((int) $metadata['package_id']);
|
||||||
|
if ($package) {
|
||||||
|
return $package;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id');
|
||||||
|
|
||||||
|
if ($priceId) {
|
||||||
|
$package = Package::where('paddle_price_id', $priceId)->first();
|
||||||
|
if ($package) {
|
||||||
|
return $package;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
|
||||||
|
$priceId = Arr::get($subscription, 'data.items.0.price_id') ?? Arr::get($subscription, 'data.items.0.price.id');
|
||||||
|
|
||||||
|
if ($priceId) {
|
||||||
|
return Package::where('paddle_price_id', $priceId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveSubscriptionExpiry(array $data): ?Carbon
|
||||||
|
{
|
||||||
|
$nextBilling = Arr::get($data, 'next_billing_date') ?? Arr::get($data, 'next_payment_date');
|
||||||
|
|
||||||
|
if ($nextBilling) {
|
||||||
|
return Carbon::parse($nextBilling);
|
||||||
|
}
|
||||||
|
|
||||||
|
$endsAt = Arr::get($data, 'billing_period_ends_at') ?? Arr::get($data, 'pays_out_at');
|
||||||
|
|
||||||
|
return $endsAt ? Carbon::parse($endsAt) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveSubscriptionStart(array $data): Carbon
|
||||||
|
{
|
||||||
|
$created = Arr::get($data, 'created_at') ?? Arr::get($data, 'activated_at');
|
||||||
|
|
||||||
|
return $created ? Carbon::parse($created) : now();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isSubscriptionActive(string $status): bool
|
||||||
|
{
|
||||||
|
return in_array($status, ['active', 'trialing'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mapSubscriptionStatus(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'active', 'trialing' => 'active',
|
||||||
|
'paused' => 'suspended',
|
||||||
|
'cancelled', 'past_due', 'halted' => 'expired',
|
||||||
|
default => 'free',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected function mergeProviderMetadata(CheckoutSession $session, array $data): void
|
protected function mergeProviderMetadata(CheckoutSession $session, array $data): void
|
||||||
{
|
{
|
||||||
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
|
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
|
||||||
@@ -230,42 +425,45 @@ class CheckoutWebhookService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession
|
protected function locatePaddleSession(array $data): ?CheckoutSession
|
||||||
{
|
{
|
||||||
if ($orderId) {
|
$metadata = $data['metadata'] ?? [];
|
||||||
$session = CheckoutSession::query()
|
|
||||||
->where('paypal_order_id', $orderId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($session) {
|
if (is_array($metadata)) {
|
||||||
|
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||||
|
|
||||||
|
if ($sessionId && $session = CheckoutSession::find($sessionId)) {
|
||||||
return $session;
|
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);
|
$checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id');
|
||||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
|
||||||
|
|
||||||
if ($sessionId) {
|
if ($checkoutId) {
|
||||||
return CheckoutSession::find($sessionId);
|
return CheckoutSession::query()
|
||||||
|
->where('provider_metadata->paddle_checkout_id', $checkoutId)
|
||||||
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extractPayPalMetadata(array $resource): array
|
|
||||||
{
|
|
||||||
$customId = $resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? null);
|
|
||||||
if ($customId) {
|
|
||||||
$decoded = json_decode($customId, true);
|
|
||||||
if (is_array($decoded)) {
|
|
||||||
return $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta = Arr::get($resource, 'supplementary_data.related_ids', []);
|
|
||||||
return is_array($meta) ? $meta : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function extractStripeChargeId(array $intent): ?string
|
protected function extractStripeChargeId(array $intent): ?string
|
||||||
{
|
{
|
||||||
$charges = $intent['charges']['data'] ?? null;
|
$charges = $intent['charges']['data'] ?? null;
|
||||||
@@ -276,4 +474,3 @@ class CheckoutWebhookService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
app/Services/Paddle/Exceptions/PaddleException.php
Normal file
23
app/Services/Paddle/Exceptions/PaddleException.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
245
app/Services/Paddle/PaddleCatalogService.php
Normal file
245
app/Services/Paddle/PaddleCatalogService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Services/Paddle/PaddleCheckoutService.php
Normal file
84
app/Services/Paddle/PaddleCheckoutService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/Services/Paddle/PaddleClient.php
Normal file
82
app/Services/Paddle/PaddleClient.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Services/Paddle/PaddleCustomerService.php
Normal file
41
app/Services/Paddle/PaddleCustomerService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Services/Paddle/PaddleSubscriptionService.php
Normal file
33
app/Services/Paddle/PaddleSubscriptionService.php
Normal 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', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Services/Paddle/PaddleTransactionService.php
Normal file
92
app/Services/Paddle/PaddleTransactionService.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,6 +43,8 @@ trait PresentsPackages
|
|||||||
'slug' => $package->slug,
|
'slug' => $package->slug,
|
||||||
'type' => $package->type,
|
'type' => $package->type,
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
|
'paddle_product_id' => $package->paddle_product_id,
|
||||||
|
'paddle_price_id' => $package->paddle_price_id,
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
'description_breakdown' => $table,
|
'description_breakdown' => $table,
|
||||||
'gallery_duration_label' => $galleryDuration,
|
'gallery_duration_label' => $galleryDuration,
|
||||||
@@ -111,20 +113,20 @@ trait PresentsPackages
|
|||||||
{
|
{
|
||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
if (!$days || $days <= 0) {
|
if (! $days || $days <= 0) {
|
||||||
return $locale === 'en' ? 'permanent' : 'dauerhaft';
|
return $locale === 'en' ? 'permanent' : 'dauerhaft';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($days % 30 === 0) {
|
if ($days % 30 === 0) {
|
||||||
$months = (int) ($days / 30);
|
$months = (int) ($days / 30);
|
||||||
if ($locale === 'en') {
|
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
|
protected function normaliseFeatures(mixed $features): array
|
||||||
@@ -144,6 +146,7 @@ trait PresentsPackages
|
|||||||
foreach ($features as $key => $value) {
|
foreach ($features as $key => $value) {
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
$list[] = $value;
|
$list[] = $value;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"laravel/wayfinder": "^0.1.9",
|
"laravel/wayfinder": "^0.1.9",
|
||||||
"league/commonmark": "^2.7",
|
"league/commonmark": "^2.7",
|
||||||
"paypal/paypal-server-sdk": "^1.1",
|
|
||||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||||
"spatie/laravel-translatable": "^6.11",
|
"spatie/laravel-translatable": "^6.11",
|
||||||
"staudenmeir/belongs-to-through": "^2.17",
|
"staudenmeir/belongs-to-through": "^2.17",
|
||||||
|
|||||||
318
composer.lock
generated
318
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c4ce377acba80c944149cab30605d24c",
|
"content-hash": "5409eee4f26e2827449d85cf6b40209d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -72,222 +72,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-07-30T15:45:57+00:00"
|
"time": "2025-07-30T15:45:57+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "apimatic/core",
|
|
||||||
"version": "0.3.14",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/apimatic/core-lib-php.git",
|
|
||||||
"reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/apimatic/core-lib-php/zipball/c3eaad6cf0c00b793ce6d9bee8b87176247da582",
|
|
||||||
"reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"apimatic/core-interfaces": "~0.1.5",
|
|
||||||
"apimatic/jsonmapper": "^3.1.1",
|
|
||||||
"ext-curl": "*",
|
|
||||||
"ext-dom": "*",
|
|
||||||
"ext-json": "*",
|
|
||||||
"ext-libxml": "*",
|
|
||||||
"php": "^7.2 || ^8.0",
|
|
||||||
"php-jsonpointer/php-jsonpointer": "^3.0.2",
|
|
||||||
"psr/log": "^1.1.4 || ^2.0.0 || ^3.0.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phan/phan": "5.4.5",
|
|
||||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
|
|
||||||
"squizlabs/php_codesniffer": "^3.5"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Core\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"description": "Core logic and the utilities for the Apimatic's PHP SDK",
|
|
||||||
"homepage": "https://github.com/apimatic/core-lib-php",
|
|
||||||
"keywords": [
|
|
||||||
"apimatic",
|
|
||||||
"core",
|
|
||||||
"corelib",
|
|
||||||
"php"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/apimatic/core-lib-php/issues",
|
|
||||||
"source": "https://github.com/apimatic/core-lib-php/tree/0.3.14"
|
|
||||||
},
|
|
||||||
"time": "2025-02-27T06:03:30+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "apimatic/core-interfaces",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/apimatic/core-interfaces-php.git",
|
|
||||||
"reference": "b4f1bffc8be79584836f70af33c65e097eec155c"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/apimatic/core-interfaces-php/zipball/b4f1bffc8be79584836f70af33c65e097eec155c",
|
|
||||||
"reference": "b4f1bffc8be79584836f70af33c65e097eec155c",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.2 || ^8.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"CoreInterfaces\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"description": "Definition of the behavior of apimatic/core, apimatic/unirest-php and Apimatic's PHP SDK",
|
|
||||||
"homepage": "https://github.com/apimatic/core-interfaces-php",
|
|
||||||
"keywords": [
|
|
||||||
"apimatic",
|
|
||||||
"core",
|
|
||||||
"corelib",
|
|
||||||
"interface",
|
|
||||||
"php",
|
|
||||||
"unirest"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/apimatic/core-interfaces-php/issues",
|
|
||||||
"source": "https://github.com/apimatic/core-interfaces-php/tree/0.1.5"
|
|
||||||
},
|
|
||||||
"time": "2024-05-09T06:32:07+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "apimatic/jsonmapper",
|
|
||||||
"version": "3.1.6",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/apimatic/jsonmapper.git",
|
|
||||||
"reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/apimatic/jsonmapper/zipball/c6cc21bd56bfe5d5822bbd08f514be465c0b24e7",
|
|
||||||
"reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-json": "*",
|
|
||||||
"php": "^5.6 || ^7.0 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0",
|
|
||||||
"squizlabs/php_codesniffer": "^3.0.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"apimatic\\jsonmapper\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"OSL-3.0"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Christian Weiske",
|
|
||||||
"email": "christian.weiske@netresearch.de",
|
|
||||||
"homepage": "http://www.netresearch.de/",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Mehdi Jaffery",
|
|
||||||
"email": "mehdi.jaffery@apimatic.io",
|
|
||||||
"homepage": "http://apimatic.io/",
|
|
||||||
"role": "Developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Map nested JSON structures onto PHP classes",
|
|
||||||
"support": {
|
|
||||||
"email": "mehdi.jaffery@apimatic.io",
|
|
||||||
"issues": "https://github.com/apimatic/jsonmapper/issues",
|
|
||||||
"source": "https://github.com/apimatic/jsonmapper/tree/3.1.6"
|
|
||||||
},
|
|
||||||
"time": "2024-11-28T09:15:32+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "apimatic/unirest-php",
|
|
||||||
"version": "4.0.7",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/apimatic/unirest-php.git",
|
|
||||||
"reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/apimatic/unirest-php/zipball/bdfd5f27c105772682c88ed671683f1bd93f4a3c",
|
|
||||||
"reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"apimatic/core-interfaces": "^0.1.0",
|
|
||||||
"ext-curl": "*",
|
|
||||||
"ext-json": "*",
|
|
||||||
"php": "^7.2 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phan/phan": "5.4.2",
|
|
||||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
|
|
||||||
"squizlabs/php_codesniffer": "^3.5"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Unirest\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Mashape",
|
|
||||||
"email": "opensource@mashape.com",
|
|
||||||
"homepage": "https://www.mashape.com",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "APIMATIC",
|
|
||||||
"email": "opensource@apimatic.io",
|
|
||||||
"homepage": "https://www.apimatic.io",
|
|
||||||
"role": "Developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Unirest PHP",
|
|
||||||
"homepage": "https://github.com/apimatic/unirest-php",
|
|
||||||
"keywords": [
|
|
||||||
"client",
|
|
||||||
"curl",
|
|
||||||
"http",
|
|
||||||
"https",
|
|
||||||
"rest"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"email": "opensource@apimatic.io",
|
|
||||||
"issues": "https://github.com/apimatic/unirest-php/issues",
|
|
||||||
"source": "https://github.com/apimatic/unirest-php/tree/4.0.7"
|
|
||||||
},
|
|
||||||
"time": "2025-06-17T09:09:48+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
"version": "2.0.8",
|
"version": "2.0.8",
|
||||||
@@ -4974,50 +4758,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2020-10-15T08:29:30+00:00"
|
"time": "2020-10-15T08:29:30+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "paypal/paypal-server-sdk",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/paypal/PayPal-PHP-Server-SDK.git",
|
|
||||||
"reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/paypal/PayPal-PHP-Server-SDK/zipball/3964c1732b1815fa8cf8aee37069ccc4e95d9572",
|
|
||||||
"reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"apimatic/core": "~0.3.13",
|
|
||||||
"apimatic/core-interfaces": "~0.1.5",
|
|
||||||
"apimatic/unirest-php": "^4.0.6",
|
|
||||||
"ext-curl": "*",
|
|
||||||
"ext-json": "*",
|
|
||||||
"php": "^7.2 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phan/phan": "5.4.5",
|
|
||||||
"squizlabs/php_codesniffer": "^3.5"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"PaypalServerSdkLib\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"description": "PayPal's SDK for interacting with the REST APIs",
|
|
||||||
"homepage": "https://github.com/paypal/PayPal-PHP-Server-SDK",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/paypal/PayPal-PHP-Server-SDK/issues",
|
|
||||||
"source": "https://github.com/paypal/PayPal-PHP-Server-SDK/tree/1.1.0"
|
|
||||||
},
|
|
||||||
"time": "2025-05-27T17:46:31+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "phenx/php-font-lib",
|
"name": "phenx/php-font-lib",
|
||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
@@ -5108,62 +4848,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2022-03-07T12:52:04+00:00"
|
"time": "2022-03-07T12:52:04+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "php-jsonpointer/php-jsonpointer",
|
|
||||||
"version": "v3.0.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/raphaelstolt/php-jsonpointer.git",
|
|
||||||
"reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/raphaelstolt/php-jsonpointer/zipball/4428f86c6f23846e9faa5a420c4ef14e485b3afb",
|
|
||||||
"reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=5.4"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"friendsofphp/php-cs-fixer": "^1.11",
|
|
||||||
"phpunit/phpunit": "4.6.*"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "2.0.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-0": {
|
|
||||||
"Rs\\Json": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Raphael Stolt",
|
|
||||||
"email": "raphael.stolt@gmail.com",
|
|
||||||
"homepage": "http://raphaelstolt.blogspot.com/"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Implementation of JSON Pointer (http://tools.ietf.org/html/rfc6901)",
|
|
||||||
"homepage": "https://github.com/raphaelstolt/php-jsonpointer",
|
|
||||||
"keywords": [
|
|
||||||
"json",
|
|
||||||
"json pointer",
|
|
||||||
"json traversal"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/raphaelstolt/php-jsonpointer/issues",
|
|
||||||
"source": "https://github.com/raphaelstolt/php-jsonpointer/tree/master"
|
|
||||||
},
|
|
||||||
"time": "2016-08-29T08:51:01+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "phpoption/phpoption",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
|
|||||||
33
config/paddle.php
Normal file
33
config/paddle.php
Normal 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,
|
||||||
|
];
|
||||||
@@ -43,10 +43,17 @@ return [
|
|||||||
'sandbox' => env('PAYPAL_SANDBOX', true),
|
'sandbox' => env('PAYPAL_SANDBOX', true),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'paddle' => [
|
||||||
|
'api_key' => env('PADDLE_API_KEY'),
|
||||||
|
'client_id' => env('PADDLE_CLIENT_ID'),
|
||||||
|
'sandbox' => env('PADDLE_SANDBOX', false),
|
||||||
|
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
|
||||||
|
],
|
||||||
|
|
||||||
'google' => [
|
'google' => [
|
||||||
'client_id' => env('GOOGLE_CLIENT_ID'),
|
'client_id' => env('GOOGLE_CLIENT_ID'),
|
||||||
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||||
'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' => [
|
'revenuecat' => [
|
||||||
@@ -63,11 +70,11 @@ return [
|
|||||||
$redirects = [];
|
$redirects = [];
|
||||||
|
|
||||||
$devServer = env('VITE_DEV_SERVER_URL');
|
$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');
|
$appUrl = env('APP_URL');
|
||||||
if ($appUrl) {
|
if ($appUrl) {
|
||||||
$redirects[] = rtrim($appUrl, '/') . '/event-admin/auth/callback';
|
$redirects[] = rtrim($appUrl, '/').'/event-admin/auth/callback';
|
||||||
} else {
|
} else {
|
||||||
$redirects[] = 'http://localhost:8000/event-admin/auth/callback';
|
$redirects[] = 'http://localhost:8000/event-admin/auth/callback';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ class PackageFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$name = $this->faker->word();
|
$name = $this->faker->word();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'slug' => Str::slug($name . '-' . uniqid()),
|
'slug' => Str::slug($name.'-'.uniqid()),
|
||||||
'description' => $this->faker->sentence(),
|
'description' => $this->faker->sentence(),
|
||||||
'price' => $this->faker->randomFloat(2, 0, 100),
|
'price' => $this->faker->randomFloat(2, 0, 100),
|
||||||
'max_photos' => $this->faker->numberBetween(100, 1000),
|
'max_photos' => $this->faker->numberBetween(100, 1000),
|
||||||
@@ -29,6 +30,9 @@ class PackageFactory extends Factory
|
|||||||
'advanced_analytics' => $this->faker->boolean(),
|
'advanced_analytics' => $this->faker->boolean(),
|
||||||
]),
|
]),
|
||||||
'type' => $this->faker->randomElement(['endcustomer', 'reseller']),
|
'type' => $this->faker->randomElement(['endcustomer', 'reseller']),
|
||||||
|
'paddle_sync_status' => null,
|
||||||
|
'paddle_synced_at' => null,
|
||||||
|
'paddle_snapshot' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class PackagePurchaseFactory extends Factory
|
|||||||
return [
|
return [
|
||||||
'tenant_id' => Tenant::factory(),
|
'tenant_id' => Tenant::factory(),
|
||||||
'package_id' => Package::factory(),
|
'package_id' => Package::factory(),
|
||||||
|
'provider' => 'manual',
|
||||||
'provider_id' => $this->faker->uuid(),
|
'provider_id' => $this->faker->uuid(),
|
||||||
'price' => $this->faker->randomFloat(2, 0, 500),
|
'price' => $this->faker->randomFloat(2, 0, 500),
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ return new class extends Migration
|
|||||||
$table->string('stripe_payment_intent_id')->nullable();
|
$table->string('stripe_payment_intent_id')->nullable();
|
||||||
$table->string('stripe_customer_id')->nullable();
|
$table->string('stripe_customer_id')->nullable();
|
||||||
$table->string('stripe_subscription_id')->nullable();
|
$table->string('stripe_subscription_id')->nullable();
|
||||||
$table->string('paypal_order_id')->nullable();
|
$table->string('paddle_checkout_id')->nullable();
|
||||||
$table->string('paypal_subscription_id')->nullable();
|
$table->string('paddle_transaction_id')->nullable();
|
||||||
$table->json('provider_metadata')->nullable();
|
$table->json('provider_metadata')->nullable();
|
||||||
|
|
||||||
$table->string('locale', 5)->nullable();
|
$table->string('locale', 5)->nullable();
|
||||||
@@ -47,7 +47,8 @@ return new class extends Migration
|
|||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
|
|
||||||
$table->unique('stripe_payment_intent_id');
|
$table->unique('stripe_payment_intent_id');
|
||||||
$table->unique('paypal_order_id');
|
$table->unique('paddle_checkout_id');
|
||||||
|
$table->unique('paddle_transaction_id');
|
||||||
$table->index(['provider', 'status']);
|
$table->index(['provider', 'status']);
|
||||||
$table->index('expires_at');
|
$table->index('expires_at');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -149,6 +149,7 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
$purchase = PackagePurchase::create([
|
$purchase = PackagePurchase::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $premium->id,
|
'package_id' => $premium->id,
|
||||||
|
'provider' => 'stripe',
|
||||||
'provider_id' => 'stripe_demo_pi',
|
'provider_id' => 'stripe_demo_pi',
|
||||||
'price' => $premium->price,
|
'price' => $premium->price,
|
||||||
'type' => 'endcustomer_event',
|
'type' => 'endcustomer_event',
|
||||||
@@ -188,6 +189,7 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'event_id' => $draftEvent->id,
|
'event_id' => $draftEvent->id,
|
||||||
'package_id' => $standard->id,
|
'package_id' => $standard->id,
|
||||||
|
'provider' => 'paypal',
|
||||||
'provider_id' => 'paypal_demo_capture',
|
'provider_id' => 'paypal_demo_capture',
|
||||||
'price' => $standard->price,
|
'price' => $standard->price,
|
||||||
'type' => 'endcustomer_event',
|
'type' => 'endcustomer_event',
|
||||||
@@ -223,6 +225,7 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
PackagePurchase::create([
|
PackagePurchase::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $reseller->id,
|
'package_id' => $reseller->id,
|
||||||
|
'provider' => 'stripe',
|
||||||
'provider_id' => 'stripe_demo_subscription',
|
'provider_id' => 'stripe_demo_subscription',
|
||||||
'price' => $reseller->price,
|
'price' => $reseller->price,
|
||||||
'type' => 'reseller_subscription',
|
'type' => 'reseller_subscription',
|
||||||
@@ -253,6 +256,7 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $standard->id,
|
'package_id' => $standard->id,
|
||||||
|
'provider' => 'manual',
|
||||||
'provider_id' => 'reseller_allowance',
|
'provider_id' => 'reseller_allowance',
|
||||||
'price' => 0,
|
'price' => 0,
|
||||||
'type' => 'endcustomer_event',
|
'type' => 'endcustomer_event',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
### Backend (MarketingRegisterController.php)
|
### Backend (MarketingRegisterController.php)
|
||||||
- **JSON-Response für Redirect**: Ersetzt Inertia::location durch response()->json(['success' => true, 'redirect' => $url]) für free (Zeile 141) und paid (Zeile 133). Kompatibel mit Inertia onSuccess (page.props.success/redirect prüfen).
|
- **JSON-Response für Redirect**: Ersetzt Inertia::location durch response()->json(['success' => true, 'redirect' => $url]) für free (Zeile 141) und paid (Zeile 133). Kompatibel mit Inertia onSuccess (page.props.success/redirect prüfen).
|
||||||
- **Tenant Name Fix**: 'name' => $request->first_name . ' ' . $request->last_name (Zeile 71); slug entsprechend angepasst.
|
- **Tenant Name Fix**: 'name' => $request->first_name . ' ' . $request->last_name (Zeile 71); slug entsprechend angepasst.
|
||||||
- **Role-Logic**: 'role' => 'user' in User::create (Zeile 66); für free: Update zu 'tenant_admin' nach TenantPackage::create (Zeile 129), Re-Login (Zeile 130). Für paid: Kein Upgrade bis Webhook (Stripe/PayPal).
|
- **Role-Logic**: 'role' => 'user' in User::create (Zeile 66); für free: Update zu 'tenant_admin' nach TenantPackage::create (Zeile 129), Re-Login (Zeile 130). Für paid: Kein Upgrade bis Webhook (Paddle).
|
||||||
- **Return-Type**: store() zu JsonResponse (Zeile 44); use JsonResponse hinzugefügt (Zeile 22).
|
- **Return-Type**: store() zu JsonResponse (Zeile 44); use JsonResponse hinzugefügt (Zeile 22).
|
||||||
|
|
||||||
### Frontend (Register.tsx)
|
### Frontend (Register.tsx)
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
- **Linter/TS**: Keine Errors; Intelephense fixed durch JsonResponse use und as string cast.
|
- **Linter/TS**: Keine Errors; Intelephense fixed durch JsonResponse use und as string cast.
|
||||||
|
|
||||||
## PRP-Update (docs/prp/13-backend-authentication.md)
|
## PRP-Update (docs/prp/13-backend-authentication.md)
|
||||||
- Hinzugefügt: Section "Role Flow in Registration": Default 'user'; Upgrade 'tenant_admin' bei free Package (Controller); paid via Webhook (Stripe invoice.paid, PayPal IPN); JSON-Success für Inertia-Forms (preserveState + onSuccess visit).
|
- Hinzugefügt: Section "Role Flow in Registration": Default 'user'; Upgrade 'tenant_admin' bei free Package (Controller); paid via Webhook (Stripe invoice.paid, Paddle IPN); JSON-Success für Inertia-Forms (preserveState + onSuccess visit).
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
- Inertia-Forms: Bei preserveState JSON-Response für custom Redirects verwenden, statt location() (vermeidet State-Ignorieren).
|
- Inertia-Forms: Bei preserveState JSON-Response für custom Redirects verwenden, statt location() (vermeidet State-Ignorieren).
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
### Wizard Foundations
|
### Wizard Foundations
|
||||||
- [x] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes (see `resources/js/pages/marketing/checkout/steps/PackageStep.tsx`).
|
- [x] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes (see `resources/js/pages/marketing/checkout/steps/PackageStep.tsx`).
|
||||||
- [x] Redesign the payment step: Stripe and PayPal happy path, failure, retry; add subscription handling for reseller plans. *(Stripe intent lifecycle + PayPal subscription flow now share status alerts, retry logic, and plan gating in `PaymentStep.tsx`.)*
|
- [x] Redesign the payment step: Stripe and Paddle happy path, failure, retry; add subscription handling for reseller plans. *(Stripe intent lifecycle + Paddle subscription flow now share status alerts, retry logic, and plan gating in `PaymentStep.tsx`.)*
|
||||||
- [x] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`. *(Handled via `ConfirmationStep.tsx` + wizard callbacks redirecting to `/settings/profile` and `/event-admin`.)*
|
- [x] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`. *(Handled via `ConfirmationStep.tsx` + wizard callbacks redirecting to `/settings/profile` and `/event-admin`.)*
|
||||||
|
|
||||||
### Authentication & Profile Data
|
### Authentication & Profile Data
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
- [x] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration. *(Legacy components removed; new wizard replaces them.)*
|
- [x] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration. *(Legacy components removed; new wizard replaces them.)*
|
||||||
|
|
||||||
### Quality & Rollout
|
### Quality & Rollout
|
||||||
- [x] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints. *(Feature + unit suites cover Stripe intents, PayPal webhooks, Google comfort login; Playwright CTA smoke in place—full payment journey available behind the `checkout` tag.)*
|
- [x] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints. *(Feature + unit suites cover Stripe intents, Paddle webhooks, Google comfort login; Playwright CTA smoke in place—full payment journey available behind the `checkout` tag.)*
|
||||||
- [x] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard.
|
- [x] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -34,6 +34,6 @@
|
|||||||
- [x] Define provider-agnostic payment state machine (intent creation, approval, capture, failure). See docs/prp/marketing-checkout-payment-architecture.md.
|
- [x] Define provider-agnostic payment state machine (intent creation, approval, capture, failure). See docs/prp/marketing-checkout-payment-architecture.md.
|
||||||
- [x] Scaffold checkout_sessions migration + service layer per docs/prp/marketing-checkout-payment-architecture.md.
|
- [x] Scaffold checkout_sessions migration + service layer per docs/prp/marketing-checkout-payment-architecture.md.
|
||||||
- [x] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session. *(Covered by `CheckoutController::createPaymentIntent`.)*
|
- [x] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session. *(Covered by `CheckoutController::createPaymentIntent`.)*
|
||||||
- [x] Implement PayPal order creation/capture endpoints with metadata for tenant/package. *(Routes now exposed in `routes/web.php`; controller derives tenant context for authenticated users.)*
|
- [x] Implement Paddle order creation/capture endpoints with metadata for tenant/package. *(Routes now exposed in `routes/web.php`; controller derives tenant context for authenticated users.)*
|
||||||
- [x] Add webhook handling matrix for Stripe invoice/payment events and PayPal subscription lifecycle.
|
- [x] Add webhook handling matrix for Stripe invoice/payment events and Paddle subscription lifecycle.
|
||||||
- [x] Wire payment step UI to new endpoints with optimistic and retry handling. *(See `PaymentStep.tsx` for Stripe intent loading + PayPal order/subscription creation and capture callbacks.)*
|
- [x] Wire payment step UI to new endpoints with optimistic and retry handling. *(See `PaymentStep.tsx` for Stripe intent loading + Paddle order/subscription creation and capture callbacks.)*
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# PayPal SDK Migration to v1 Server SDK
|
# Paddle SDK Migration to v1 Server SDK
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-sdk ^1.0+` in PayPalController.php. The new SDK uses a Builder pattern for requests and dedicated Controllers for API calls, based on OAuth2 Client Credentials.
|
Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-sdk ^1.0+` in PaddleController.php. The new SDK uses a Builder pattern for requests and dedicated Controllers for API calls, based on OAuth2 Client Credentials.
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- **Composer**: Removed `paypal/paypal-checkout-sdk`; retained/updated `paypal/paypal-server-sdk`.
|
- **Composer**: Removed `paypal/paypal-checkout-sdk`; retained/updated `paypal/paypal-server-sdk`.
|
||||||
- **Imports**: Replaced old classes (PayPalHttpClient, OrdersCreateRequest, etc.) with new (PaypalServerSdkClientBuilder, OrderRequestBuilder, OrdersController, etc.).
|
- **Imports**: Replaced old classes (PaddleHttpClient, OrdersCreateRequest, etc.) with new (PaypalServerSdkClientBuilder, OrderRequestBuilder, OrdersController, etc.).
|
||||||
- **Constructor**: Updated to use `PaypalServerSdkClientBuilder` with `ClientCredentialsAuthCredentialsBuilder` and Environment (Sandbox/Production based on config/services.php).
|
- **Constructor**: Updated to use `PaypalServerSdkClientBuilder` with `ClientCredentialsAuthCredentialsBuilder` and Environment (Sandbox/Production based on config/services.php).
|
||||||
- **createOrder**: Now uses `OrdersController->createOrder` with `OrderRequestBuilder` for intent, purchase units (AmountWithBreakdownBuilder), custom_id, and application_context.
|
- **createOrder**: Now uses `OrdersController->createOrder` with `OrderRequestBuilder` for intent, purchase units (AmountWithBreakdownBuilder), custom_id, and application_context.
|
||||||
- **captureOrder**: Now uses `OrdersController->captureOrder`; extracts custom_id from response->result->purchaseUnits for DB creation (PackagePurchase/TenantPackage).
|
- **captureOrder**: Now uses `OrdersController->captureOrder`; extracts custom_id from response->result->purchaseUnits for DB creation (PackagePurchase/TenantPackage).
|
||||||
@@ -14,9 +14,9 @@ Migrated from deprecated `paypal/paypal-checkout-sdk` to `paypal/paypal-server-s
|
|||||||
- **Documentation**: Updated docs/prp/08-billing.md to reflect new SDK usage, flow, and migration notes.
|
- **Documentation**: Updated docs/prp/08-billing.md to reflect new SDK usage, flow, and migration notes.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- Unit/Feature Tests: All PayPal-related tests pass with mocks simulating new API responses (statusCode 201, result structure).
|
- Unit/Feature Tests: All Paddle-related tests pass with mocks simulating new API responses (statusCode 201, result structure).
|
||||||
- Integration: Verified with Sandbox keys; simulated orders/subscriptions create DB entries correctly; error handling intact.
|
- Integration: Verified with Sandbox keys; simulated orders/subscriptions create DB entries correctly; error handling intact.
|
||||||
- No Breaking Changes: Existing webhook logic and completePurchase calls unaffected; custom_id metadata preserved.
|
- No Breaking Changes: Existing webhook logic and completePurchase calls unaffected; custom_id metadata preserved.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
The old SDK is deprecated and not recommended by PayPal. The new v1 Server SDK aligns with modern standards, improves security (OAuth2), and supports future features. Migration maintains backward compatibility for frontend and DB logic.
|
The old SDK is deprecated and not recommended by Paddle. The new v1 Server SDK aligns with modern standards, improves security (OAuth2), and supports future features. Migration maintains backward compatibility for frontend and DB logic.
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
- **Credits strip**: `credits-card` combines balance chips, a RevenueCat-aware badge, and CTA to `/credits-store`; replicating this card gives tenants a quick read on package status.
|
- **Credits strip**: `credits-card` combines balance chips, a RevenueCat-aware badge, and CTA to `/credits-store`; replicating this card gives tenants a quick read on package status.
|
||||||
|
|
||||||
### Monetisation & Ordering
|
### Monetisation & Ordering
|
||||||
- **IAP store** (`pages/IAPStorePage.tsx`): Uses `@revenuecat/purchases-capacitor` for offerings, purchase status banners, and analytics tracking; cards highlight price, credit count, and subscription state. Needs Stripe/PayPal parity discussion before porting.
|
- **IAP store** (`pages/IAPStorePage.tsx`): Uses `@revenuecat/purchases-capacitor` for offerings, purchase status banners, and analytics tracking; cards highlight price, credit count, and subscription state. Needs Paddle parity discussion before porting.
|
||||||
- **Credits context** (`contexts/AuthContext.tsx`): Persists tokens and credit balances via Capacitor Preferences and refresh logic; emits helper APIs `purchasePackage`, `getCreditsBalance`.
|
- **Credits context** (`contexts/AuthContext.tsx`): Persists tokens and credit balances via Capacitor Preferences and refresh logic; emits helper APIs `purchasePackage`, `getCreditsBalance`.
|
||||||
|
|
||||||
### Event Creation Wizard
|
### Event Creation Wizard
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
**Porting Recommendation**
|
**Porting Recommendation**
|
||||||
- Rebuild the hero, feature cards, quick actions, and wizard using Tailwind + shadcn components inside Laravel PWA while reusing copy/structure.
|
- Rebuild the hero, feature cards, quick actions, and wizard using Tailwind + shadcn components inside Laravel PWA while reusing copy/structure.
|
||||||
- Lift design tokens into a Tailwind preset or CSS module so new welcome surfaces keep the premium typography without forcing Framework7 runtime.
|
- Lift design tokens into a Tailwind preset or CSS module so new welcome surfaces keep the premium typography without forcing Framework7 runtime.
|
||||||
- Treat RevenueCat-specific logic as optional: plan abstraction so Stripe/PayPal packages in Laravel can slot in later if we skip native IAPs initially.
|
- Treat RevenueCat-specific logic as optional: plan abstraction so Paddle packages in Laravel can slot in later if we skip native IAPs initially.
|
||||||
|
|
||||||
## Proposed Laravel PWA Welcome Primitives
|
## Proposed Laravel PWA Welcome Primitives
|
||||||
- **`TenantWelcomeLayout`**: Full-height, gradient-backed shell with centered content column, safe-area padding, and optional bottom action rail. Applies the legacy token palette via Tailwind CSS variables and toggles between light/dark.
|
- **`TenantWelcomeLayout`**: Full-height, gradient-backed shell with centered content column, safe-area padding, and optional bottom action rail. Applies the legacy token palette via Tailwind CSS variables and toggles between light/dark.
|
||||||
@@ -71,13 +71,13 @@
|
|||||||
These primitives live under `resources/js/admin/onboarding/` and integrate with current router constants (`ADMIN_BASE_PATH`). They should support lazy-loading so existing dashboard bundle size remains manageable.
|
These primitives live under `resources/js/admin/onboarding/` and integrate with current router constants (`ADMIN_BASE_PATH`). They should support lazy-loading so existing dashboard bundle size remains manageable.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
- **Inline Checkout**: Die Order-Summary-Seite unterstützt jetzt Stripe-Kartenzahlungen (Payment Element) und PayPal (Orders API) direkt aus dem Onboarding heraus. Free-Packages lassen sich ohne Umweg aktivieren.
|
- **Inline Checkout**: Die Order-Summary-Seite unterstützt jetzt Stripe-Kartenzahlungen (Payment Element) und Paddle (Orders API) direkt aus dem Onboarding heraus. Free-Packages lassen sich ohne Umweg aktivieren.
|
||||||
- Dashboard bewirbt die Welcome Journey (Actions + Hero Card) und leitet Tenants ohne Events weiterhin auf `/event-admin/welcome` um, während Fortschritt persistiert wird.
|
- Dashboard bewirbt die Welcome Journey (Actions + Hero Card) und leitet Tenants ohne Events weiterhin auf `/event-admin/welcome` um, während Fortschritt persistiert wird.
|
||||||
- Playwright-Skelett `tests/e2e/tenant-onboarding-flow.test.ts` angelegt und via `npm run test:e2e` ausführbar; Tests sind vorerst deaktiviert, bis Seed-Daten + Auth-Helper zur Verfügung stehen.
|
- Playwright-Skelett `tests/e2e/tenant-onboarding-flow.test.ts` angelegt und via `npm run test:e2e` ausführbar; Tests sind vorerst deaktiviert, bis Seed-Daten + Auth-Helper zur Verfügung stehen.
|
||||||
- Welcome Landing, Packages, Summary und Event-Setup sind zweisprachig (DE/EN) via react-i18next; LanguageSwitcher im Dashboard & Welcome-Layout steuert die Locale.
|
- Welcome Landing, Packages, Summary und Event-Setup sind zweisprachig (DE/EN) via react-i18next; LanguageSwitcher im Dashboard & Welcome-Layout steuert die Locale.
|
||||||
|
|
||||||
## Status — verbleibende Arbeiten
|
## Status — verbleibende Arbeiten
|
||||||
- PayPal-Testabdeckung (Playwright/RTL) und Error-UX gehören noch in die Roadmap, ebenso wie End-to-End-Validierung auf Staging.
|
- Paddle-Testabdeckung (Playwright/RTL) und Error-UX gehören noch in die Roadmap, ebenso wie End-to-End-Validierung auf Staging.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Keep current management modules untouched until welcome flow is ready; ship incrementally behind feature flag if needed.
|
- Keep current management modules untouched until welcome flow is ready; ship incrementally behind feature flag if needed.
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ Frage klären: kann man den login oder die registrierung ersetzen durch daten vo
|
|||||||
|
|
||||||
schritt 3: Zahlung
|
schritt 3: Zahlung
|
||||||
pakettyp "endcustomer":
|
pakettyp "endcustomer":
|
||||||
auswahl paypal / Stripe. Buttons für "Mit PayPal bezahlen" und "Mit Stripe bezahlen" anzeigen. Der Benutzer klickt einen aus.
|
auswahl paypal / Stripe. Buttons für "Mit Paddle bezahlen" und "Mit Stripe bezahlen" anzeigen. Der Benutzer klickt einen aus.
|
||||||
Zahlungsinitierung:
|
Zahlungsinitierung:
|
||||||
PayPal: Umleitung zu PayPal's Express Checkout (via API-Call in Laravel-Controller, z. B. create_order). Der Benutzer loggt sich bei PayPal ein, bestätigt den Einmalkauf (keine Subscription-Option). Rückleitung mit Token zur Bestätigung (Webhook oder Redirect-Handler).
|
Paddle: Umleitung zu Paddle's Express Checkout (via API-Call in Laravel-Controller, z. B. create_order). Der Benutzer loggt sich bei Paddle ein, bestätigt den Einmalkauf (keine Subscription-Option). Rückleitung mit Token zur Bestätigung (Webhook oder Redirect-Handler).
|
||||||
Stripe: Client-seitige Integration mit Stripe Elements (React-Komponente in Ihrer PWA). Der Benutzer gibt Kartendaten ein (ohne Umleitung), oder nutzt Stripe Checkout (hosted Page). Backend-Call zu Stripe API für PaymentIntent erstellen und bestätigen.
|
Stripe: Client-seitige Integration mit Stripe Elements (React-Komponente in Ihrer PWA). Der Benutzer gibt Kartendaten ein (ohne Umleitung), oder nutzt Stripe Checkout (hosted Page). Backend-Call zu Stripe API für PaymentIntent erstellen und bestätigen.
|
||||||
Bestätigung: Nach Zahlung (z. B. 29,99 €) wird der Kauf im Backend gespeichert (z. B. TenantPackage::createPurchase()), Zugang freigeschaltet (z. B. Event-Zugriff via EventController), und der Benutzer sieht eine Erfolgsseite.
|
Bestätigung: Nach Zahlung (z. B. 29,99 €) wird der Kauf im Backend gespeichert (z. B. TenantPackage::createPurchase()), Zugang freigeschaltet (z. B. Event-Zugriff via EventController), und der Benutzer sieht eine Erfolgsseite.
|
||||||
Fehlerbehandlung: Abbruch → Zurück zur Bestellübersicht mit Fehlermeldung (z. B. "Zahlung fehlgeschlagen").
|
Fehlerbehandlung: Abbruch → Zurück zur Bestellübersicht mit Fehlermeldung (z. B. "Zahlung fehlgeschlagen").
|
||||||
|
|
||||||
pakettyp "reseller":
|
pakettyp "reseller":
|
||||||
PayPal:
|
Paddle:
|
||||||
Nutzung von PayPal Subscriptions API (in Laravel via SDK). Erstellen eines Subscription-Plans (z. B. create_subscription), Umleitung zu PayPal für Autorisierung. Der Benutzer stimmt wiederkehrenden Abbuchungen zu. Rückleitung mit Subscription-ID, die im Backend (z. B. PackagePurchases) gespeichert wird. Webhooks für Updates (z. B. Kündigung).
|
Nutzung von Paddle Subscriptions API (in Laravel via SDK). Erstellen eines Subscription-Plans (z. B. create_subscription), Umleitung zu Paddle für Autorisierung. Der Benutzer stimmt wiederkehrenden Abbuchungen zu. Rückleitung mit Subscription-ID, die im Backend (z. B. PackagePurchases) gespeichert wird. Webhooks für Updates (z. B. Kündigung).
|
||||||
Stripe:
|
Stripe:
|
||||||
Erstellen eines Subscription-Plans via Stripe Dashboard/API (z. B. stripe.subscriptions.create()). Client-seitig: Stripe Elements für SetupIntent (Kartenspeicherung), dann Subscription aktivieren. Keine Umleitung nötig, wenn Sie benutzerdefinierte UI bauen. Backend-Handling für Billing-Cycles, Invoices und Webhooks (z. B. für invoice.paid).
|
Erstellen eines Subscription-Plans via Stripe Dashboard/API (z. B. stripe.subscriptions.create()). Client-seitig: Stripe Elements für SetupIntent (Kartenspeicherung), dann Subscription aktivieren. Keine Umleitung nötig, wenn Sie benutzerdefinierte UI bauen. Backend-Handling für Billing-Cycles, Invoices und Webhooks (z. B. für invoice.paid).
|
||||||
Bestätigung: Erste Zahlung erfolgt sofort, Subscription startet. Backend-Update: Reseller-Status aktivieren (z. B. in Tenants-Tabelle), Willkommens-E-Mail.
|
Bestätigung: Erste Zahlung erfolgt sofort, Subscription startet. Backend-Update: Reseller-Status aktivieren (z. B. in Tenants-Tabelle), Willkommens-E-Mail.
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ Der Anbieter verwendet Inhalte ausschließlich zur technischen Bereitstellung (S
|
|||||||
## 7. Preise und Zahlung
|
## 7. Preise und Zahlung
|
||||||
1. Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung.
|
1. Es gelten die auf der Website veröffentlichten Preise zum Zeitpunkt der Buchung.
|
||||||
2. Alle Preise verstehen sich einschließlich gesetzlicher Umsatzsteuer.
|
2. Alle Preise verstehen sich einschließlich gesetzlicher Umsatzsteuer.
|
||||||
3. Die Zahlung erfolgt im Voraus über **PayPal** oder **Stripe Checkout** (Kreditkarte, Apple Pay, Google Pay u. a.).
|
3. Die Zahlung erfolgt im Voraus über **Paddle** oder **Stripe Checkout** (Kreditkarte, Apple Pay, Google Pay u. a.).
|
||||||
4. Bei Nutzung dieser Dienste gelten zusätzlich die AGB und Datenschutzhinweise der jeweiligen Anbieter:
|
4. Bei Nutzung dieser Dienste gelten zusätzlich die AGB und Datenschutzhinweise der jeweiligen Anbieter:
|
||||||
– PayPal (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg
|
– Paddle (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg
|
||||||
– Stripe Payments Europe Ltd., Dublin, Irland
|
– Stripe Payments Europe Ltd., Dublin, Irland
|
||||||
5. Der Anbieter erhält von diesen Diensten nur Zahlungs- und Statusinformationen zur Abwicklung.
|
5. Der Anbieter erhält von diesen Diensten nur Zahlungs- und Statusinformationen zur Abwicklung.
|
||||||
6. Rechnungen werden elektronisch bereitgestellt.
|
6. Rechnungen werden elektronisch bereitgestellt.
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ The Provider uses such content solely for technical purposes (storage, display,
|
|||||||
## 7. Prices and Payment
|
## 7. Prices and Payment
|
||||||
1. Prices valid at the time of booking apply.
|
1. Prices valid at the time of booking apply.
|
||||||
2. All prices include VAT, unless otherwise stated.
|
2. All prices include VAT, unless otherwise stated.
|
||||||
3. Payment is made in advance via **PayPal** or **Stripe Checkout** (credit card, Apple Pay, Google Pay, etc.).
|
3. Payment is made in advance via **Paddle** or **Stripe Checkout** (credit card, Apple Pay, Google Pay, etc.).
|
||||||
4. The payment process is governed by the respective provider’s terms:
|
4. The payment process is governed by the respective provider’s terms:
|
||||||
– PayPal (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg
|
– Paddle (Europe) S.à r.l. et Cie, S.C.A., L-2449 Luxembourg
|
||||||
– Stripe Payments Europe Ltd., Dublin, Ireland
|
– Stripe Payments Europe Ltd., Dublin, Ireland
|
||||||
5. The Provider only receives transaction and payment status data necessary for processing.
|
5. The Provider only receives transaction and payment status data necessary for processing.
|
||||||
6. Invoices are issued electronically.
|
6. Invoices are issued electronically.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Die Nutzung der Fotospiel App ist grundsätzlich nur mit den personenbezogenen D
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 3. Arten der verarbeiteten Daten
|
## 3. Arten der verarbeiteten Daten
|
||||||
- Veranstalterdaten: Name, E-Mail-Adresse, Zahlungsinformationen (über PayPal/Stripe), Eventdaten (Titel, Datum, Aufgaben, Bilder)
|
- Veranstalterdaten: Name, E-Mail-Adresse, Zahlungsinformationen (über Paddle/Stripe), Eventdaten (Titel, Datum, Aufgaben, Bilder)
|
||||||
- Nutzerdaten (Gäste): hochgeladene Fotos, Anzeigename (frei wählbar), Reaktionen/Likes
|
- Nutzerdaten (Gäste): hochgeladene Fotos, Anzeigename (frei wählbar), Reaktionen/Likes
|
||||||
- Technische Daten: IP-Adresse, Browsertyp, Zeitstempel, Geräteinformationen
|
- Technische Daten: IP-Adresse, Browsertyp, Zeitstempel, Geräteinformationen
|
||||||
- Kommunikationsdaten: Inhalte von Kontaktanfragen über das Formular oder per E-Mail
|
- Kommunikationsdaten: Inhalte von Kontaktanfragen über das Formular oder per E-Mail
|
||||||
@@ -33,7 +33,7 @@ Die Nutzung der Fotospiel App ist grundsätzlich nur mit den personenbezogenen D
|
|||||||
|------------------------|----------------|---------------|
|
|------------------------|----------------|---------------|
|
||||||
| Bereitstellung der App und Durchführung von Veranstaltungen | Art. 6 Abs. 1 lit. b DSGVO | Nutzung der App durch Veranstalter und Gäste |
|
| Bereitstellung der App und Durchführung von Veranstaltungen | Art. 6 Abs. 1 lit. b DSGVO | Nutzung der App durch Veranstalter und Gäste |
|
||||||
| Speicherung und Anzeige von Fotos innerhalb des Events | Art. 6 Abs. 1 lit. b DSGVO | Durchführung der Fotospiel-Funktionalität |
|
| Speicherung und Anzeige von Fotos innerhalb des Events | Art. 6 Abs. 1 lit. b DSGVO | Durchführung der Fotospiel-Funktionalität |
|
||||||
| Abrechnung und Zahlungsabwicklung | Art. 6 Abs. 1 lit. b, lit. c DSGVO | Nutzung der Dienste von PayPal und Stripe |
|
| Abrechnung und Zahlungsabwicklung | Art. 6 Abs. 1 lit. b, lit. c DSGVO | Nutzung der Dienste von Paddle und Stripe |
|
||||||
| Webanalyse über Matomo (selbst gehostet) | Art. 6 Abs. 1 lit. f DSGVO | Statistische Auswertung zur Verbesserung der App |
|
| Webanalyse über Matomo (selbst gehostet) | Art. 6 Abs. 1 lit. f DSGVO | Statistische Auswertung zur Verbesserung der App |
|
||||||
| Sicherheit, Server-Logs | Art. 6 Abs. 1 lit. f DSGVO | Sicherstellung des Betriebs, Fehleranalyse |
|
| Sicherheit, Server-Logs | Art. 6 Abs. 1 lit. f DSGVO | Sicherstellung des Betriebs, Fehleranalyse |
|
||||||
| Beantwortung von Kontaktanfragen | Art. 6 Abs. 1 lit. f oder lit. b DSGVO | Kommunikation mit Nutzern und Interessenten |
|
| Beantwortung von Kontaktanfragen | Art. 6 Abs. 1 lit. f oder lit. b DSGVO | Kommunikation mit Nutzern und Interessenten |
|
||||||
@@ -48,13 +48,13 @@ Die Verarbeitung erfolgt ausschließlich innerhalb der EU.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 6. Zahlungsabwicklung
|
## 6. Zahlungsabwicklung
|
||||||
Die Zahlungsabwicklung erfolgt über **PayPal (Europe) S.à r.l. et Cie, S.C.A.** und **Stripe Payments Europe, Ltd.**
|
Die Zahlungsabwicklung erfolgt über **Paddle (Europe) S.à r.l. et Cie, S.C.A.** und **Stripe Payments Europe, Ltd.**
|
||||||
Bei der Zahlung werden personenbezogene Daten an diese Dienstleister übermittelt.
|
Bei der Zahlung werden personenbezogene Daten an diese Dienstleister übermittelt.
|
||||||
Wir speichern keine Zahlungs- oder Kreditkartendaten.
|
Wir speichern keine Zahlungs- oder Kreditkartendaten.
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. b und lit. c DSGVO.
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. b und lit. c DSGVO.
|
||||||
|
|
||||||
Datenschutzhinweise der Anbieter:
|
Datenschutzhinweise der Anbieter:
|
||||||
- PayPal: https://www.paypal.com/de/webapps/mpp/ua/privacy-full
|
- Paddle: https://www.paypal.com/de/webapps/mpp/ua/privacy-full
|
||||||
- Stripe: https://stripe.com/de/privacy
|
- Stripe: https://stripe.com/de/privacy
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -88,7 +88,7 @@ Eine Einwilligung ist nicht erforderlich.
|
|||||||
|
|
||||||
## 10. Weitergabe an Dritte
|
## 10. Weitergabe an Dritte
|
||||||
Eine Weitergabe erfolgt nur an:
|
Eine Weitergabe erfolgt nur an:
|
||||||
- Zahlungsdienstleister (PayPal, Stripe)
|
- Zahlungsdienstleister (Paddle, Stripe)
|
||||||
- Hoster (Hetzner)
|
- Hoster (Hetzner)
|
||||||
- Gesetzlich erforderliche Stellen (z. B. Finanzbehörden)
|
- Gesetzlich erforderliche Stellen (z. B. Finanzbehörden)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Use of the Fotospiel App requires only the personal data necessary to host and p
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 3. Types of Data Processed
|
## 3. Types of Data Processed
|
||||||
- Organizer data: name, email address, payment information (via PayPal/Stripe), event details (title, date, photo tasks, photos)
|
- Organizer data: name, email address, payment information (via Paddle/Stripe), event details (title, date, photo tasks, photos)
|
||||||
- Guest data: uploaded photos, display name (optional), likes/reactions
|
- Guest data: uploaded photos, display name (optional), likes/reactions
|
||||||
- Technical data: IP address, browser type, timestamp, device information
|
- Technical data: IP address, browser type, timestamp, device information
|
||||||
- Communication data: messages sent via contact form or email
|
- Communication data: messages sent via contact form or email
|
||||||
@@ -33,7 +33,7 @@ Use of the Fotospiel App requires only the personal data necessary to host and p
|
|||||||
|----------|--------------|-------------|
|
|----------|--------------|-------------|
|
||||||
| Providing the app and hosting events | Art. 6(1)(b) GDPR | Contract performance |
|
| Providing the app and hosting events | Art. 6(1)(b) GDPR | Contract performance |
|
||||||
| Storing and displaying photos | Art. 6(1)(b) GDPR | Core feature of the app |
|
| Storing and displaying photos | Art. 6(1)(b) GDPR | Core feature of the app |
|
||||||
| Payment processing and invoicing | Art. 6(1)(b), (c) GDPR | Use of PayPal and Stripe services |
|
| Payment processing and invoicing | Art. 6(1)(b), (c) GDPR | Use of Paddle and Stripe services |
|
||||||
| Web analytics via Matomo | Art. 6(1)(f) GDPR | Statistical analysis to improve the app |
|
| Web analytics via Matomo | Art. 6(1)(f) GDPR | Statistical analysis to improve the app |
|
||||||
| Server logs and security | Art. 6(1)(f) GDPR | Ensuring system security |
|
| Server logs and security | Art. 6(1)(f) GDPR | Ensuring system security |
|
||||||
| Responding to inquiries | Art. 6(1)(f) or (b) GDPR | Communication with users |
|
| Responding to inquiries | Art. 6(1)(f) or (b) GDPR | Communication with users |
|
||||||
@@ -48,12 +48,12 @@ All processing takes place within the EU.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 6. Payment Processing
|
## 6. Payment Processing
|
||||||
Payments are handled by **PayPal (Europe) S.à r.l. et Cie, S.C.A.** and **Stripe Payments Europe, Ltd.**
|
Payments are handled by **Paddle (Europe) S.à r.l. et Cie, S.C.A.** and **Stripe Payments Europe, Ltd.**
|
||||||
We do not store payment or credit card data.
|
We do not store payment or credit card data.
|
||||||
Legal basis: Art. 6(1)(b) and (c) GDPR.
|
Legal basis: Art. 6(1)(b) and (c) GDPR.
|
||||||
|
|
||||||
Privacy policies:
|
Privacy policies:
|
||||||
- PayPal: https://www.paypal.com/de/webapps/mpp/ua/privacy-full
|
- Paddle: https://www.paypal.com/de/webapps/mpp/ua/privacy-full
|
||||||
- Stripe: https://stripe.com/de/privacy
|
- Stripe: https://stripe.com/de/privacy
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -87,7 +87,7 @@ No consent is required.
|
|||||||
|
|
||||||
## 10. Data Disclosure
|
## 10. Data Disclosure
|
||||||
Data is only shared with:
|
Data is only shared with:
|
||||||
- Payment providers (PayPal, Stripe)
|
- Payment providers (Paddle, Stripe)
|
||||||
- Hosting provider (Hetzner)
|
- Hosting provider (Hetzner)
|
||||||
- Public authorities when legally required
|
- Public authorities when legally required
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ Das bestehende Modell ist Credits-basiert (Freemium mit 1 Free-Credit, One-off-K
|
|||||||
- **API:** Endpunkte `/api/v1/tenant/credits/balance`, `/credits/ledger`, `/credits/purchase`, `/credits/sync`, `/purchases/intent`.
|
- **API:** Endpunkte `/api/v1/tenant/credits/balance`, `/credits/ledger`, `/credits/purchase`, `/credits/sync`, `/purchases/intent`.
|
||||||
- **Frontend (Admin PWA):** Dashboard-Cards für Balance, Kauf-Integration (RevenueCat).
|
- **Frontend (Admin PWA):** Dashboard-Cards für Balance, Kauf-Integration (RevenueCat).
|
||||||
- **Guest PWA:** Keine direkten Checks (Backend-handhabt).
|
- **Guest PWA:** Keine direkten Checks (Backend-handhabt).
|
||||||
- **Billing:** Stripe (Checkout/Webhooks), RevenueCat (IAP), PayPalWebhookController (teilweise).
|
- **Billing:** Stripe (Checkout/Webhooks), RevenueCat (IAP), PaddleWebhookController (teilweise).
|
||||||
- **Tests:** `RevenueCatWebhookTest`, Credit-Unit-Tests.
|
- **Tests:** `RevenueCatWebhookTest`, Credit-Unit-Tests.
|
||||||
- **Docs:** PRP 08-billing.md (Credits-MVP), 14-freemium-business-model.md (IAP-Struktur), API-Specs (credits-Endpunkte).
|
- **Docs:** PRP 08-billing.md (Credits-MVP), 14-freemium-business-model.md (IAP-Struktur), API-Specs (credits-Endpunkte).
|
||||||
- **Lücken im Aktuellen:** Keine Package-Limits (nur Balance), Subscriptions nicht live, PayPal untergenutzt.
|
- **Lücken im Aktuellen:** Keine Package-Limits (nur Balance), Subscriptions nicht live, Paddle untergenutzt.
|
||||||
|
|
||||||
**Auswirkungen:** Vollständige Ersetzung, um Flexibilität (Limits/Features pro Package) zu ermöglichen.
|
**Auswirkungen:** Vollständige Ersetzung, um Flexibilität (Limits/Features pro Package) zu ermöglichen.
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
|
|||||||
$table->foreignId('tenant_id')->nullable()->constrained();
|
$table->foreignId('tenant_id')->nullable()->constrained();
|
||||||
$table->foreignId('event_id')->nullable()->constrained();
|
$table->foreignId('event_id')->nullable()->constrained();
|
||||||
$table->foreignId('package_id')->constrained();
|
$table->foreignId('package_id')->constrained();
|
||||||
$table->string('provider_id'); // Stripe/PayPal ID
|
$table->string('provider_id'); // Paddle ID
|
||||||
$table->decimal('price', 8, 2);
|
$table->decimal('price', 8, 2);
|
||||||
$table->enum('type', ['endcustomer_event', 'reseller_subscription']);
|
$table->enum('type', ['endcustomer_event', 'reseller_subscription']);
|
||||||
$table->json('metadata'); // {event_id, ip_address}
|
$table->json('metadata'); // {event_id, ip_address}
|
||||||
@@ -129,13 +129,13 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
|
|||||||
|
|
||||||
- **TenantPackageResource (SuperAdmin/TenantAdmin):**
|
- **TenantPackageResource (SuperAdmin/TenantAdmin):**
|
||||||
- Form: Select('tenant_id'), Select('package_id'), DateTimePicker('purchased_at'), DateTimePicker('expires_at'), TextInput('used_events', readOnly), Toggle('active').
|
- Form: Select('tenant_id'), Select('package_id'), DateTimePicker('purchased_at'), DateTimePicker('expires_at'), TextInput('used_events', readOnly), Toggle('active').
|
||||||
- Table: TextColumn('tenant.name'), BadgeColumn('package.name'), DateColumn('expires_at', color: expired → danger), ProgressColumn('used_events' / max_events), Actions (Renew: set expires_at +1 year, Cancel: active=false + Stripe/PayPal cancel).
|
- Table: TextColumn('tenant.name'), BadgeColumn('package.name'), DateColumn('expires_at', color: expired → danger), ProgressColumn('used_events' / max_events), Actions (Renew: set expires_at +1 year, Cancel: active=false + Paddle cancel).
|
||||||
- Relations: BelongsTo Tenant/Package, HasMany Events (RelationManager mit Event-List).
|
- Relations: BelongsTo Tenant/Package, HasMany Events (RelationManager mit Event-List).
|
||||||
- Bulk-Actions: Renew Selected.
|
- Bulk-Actions: Renew Selected.
|
||||||
|
|
||||||
- **PurchaseResource (SuperAdmin/TenantAdmin):**
|
- **PurchaseResource (SuperAdmin/TenantAdmin):**
|
||||||
- Form: Select('tenant_id/event_id'), Select('package_id'), TextInput('provider_id'), MoneyInput('price'), Select('type'), JSONEditor('metadata'), Toggle('refunded').
|
- Form: Select('tenant_id/event_id'), Select('package_id'), TextInput('provider_id'), MoneyInput('price'), Select('type'), JSONEditor('metadata'), Toggle('refunded').
|
||||||
- Table: BadgeColumn('type'), LinkColumn('tenant' or 'event'), TextColumn('package.name/price'), DateColumn('purchased_at'), BadgeColumn('status' – paid/refunded), Actions (View, Refund: Call Stripe/PayPal API, decrement counters, log).
|
- Table: BadgeColumn('type'), LinkColumn('tenant' or 'event'), TextColumn('package.name/price'), DateColumn('purchased_at'), BadgeColumn('status' – paid/refunded), Actions (View, Refund: Call Paddle API, decrement counters, log).
|
||||||
- Filters: SelectFilter('type'), DateRangeFilter('purchased_at'), TenantFilter.
|
- Filters: SelectFilter('type'), DateRangeFilter('purchased_at'), TenantFilter.
|
||||||
- Widgets: StatsOverview (Total Revenue, Monthly Purchases, Top Package), ChartWidget (Revenue over Time via Laravel Charts).
|
- Widgets: StatsOverview (Total Revenue, Monthly Purchases, Top Package), ChartWidget (Revenue over Time via Laravel Charts).
|
||||||
- Export: CSV (für Buchhaltung: tenant, package, price, date).
|
- Export: CSV (für Buchhaltung: tenant, package, price, date).
|
||||||
@@ -145,14 +145,14 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
|
|||||||
## 5. Marketing- und Legal-Anpassungen (Todo 4)
|
## 5. Marketing- und Legal-Anpassungen (Todo 4)
|
||||||
- **Webfrontend (Blade, resources/views/marketing/):**
|
- **Webfrontend (Blade, resources/views/marketing/):**
|
||||||
- **packages.blade.php (neu, Route /packages):** Hero ("Entdecken Sie unsere Packages"), Tabs (Endkunden/Reseller), Tabelle/Accordion mit Details (Preis, Limits als Icons, Features-Bullets, i18n-Übersetzungen). CTA: "Kaufen" → /checkout/{id}. Dynamisch: @foreach(Package::where('type', 'endcustomer')->get() as $package).
|
- **packages.blade.php (neu, Route /packages):** Hero ("Entdecken Sie unsere Packages"), Tabs (Endkunden/Reseller), Tabelle/Accordion mit Details (Preis, Limits als Icons, Features-Bullets, i18n-Übersetzungen). CTA: "Kaufen" → /checkout/{id}. Dynamisch: @foreach(Package::where('type', 'endcustomer')->get() as $package).
|
||||||
- **checkout.blade.php (neu, Route /checkout/{package_id}):** Summary-Box (Package-Details), Form (Name, E-Mail, Adresse für Reseller), Zahlungsoptionen (Radio: Stripe/PayPal), Stripe-Element/PayPal-Button. Submit: POST /purchases/intent → Redirect. Tailwind: Secure-Design mit Badges.
|
- **checkout.blade.php (neu, Route /checkout/{package_id}):** Summary-Box (Package-Details), Form (Name, E-Mail, Adresse für Reseller), Zahlungsoptionen (Radio: Paddle), Stripe-Element/Paddle-Button. Submit: POST /purchases/intent → Redirect. Tailwind: Secure-Design mit Badges.
|
||||||
- **success.blade.php:** "Vielen Dank! Package {name} gekauft." Details (Limits, Event-Link), Upsell ("Upgrade zu Reseller?"), Rechnung-Download (PDF via Dompdf), Onboarding-Tour-Link.
|
- **success.blade.php:** "Vielen Dank! Package {name} gekauft." Details (Limits, Event-Link), Upsell ("Upgrade zu Reseller?"), Rechnung-Download (PDF via Dompdf), Onboarding-Tour-Link.
|
||||||
- **marketing.blade.php:** Teaser-Section mit Package-Icons/Preisen, Link zu /packages.
|
- **marketing.blade.php:** Teaser-Section mit Package-Icons/Preisen, Link zu /packages.
|
||||||
- **occasions.blade.php/blog*.blade.php:** Kontextuelle Erwähnungen (z.B. "Ideal für Partys: Starter-Paket"), Blog-Post "Neues Package-Modell" mit FAQ.
|
- **occasions.blade.php/blog*.blade.php:** Kontextuelle Erwähnungen (z.B. "Ideal für Partys: Starter-Paket"), Blog-Post "Neues Package-Modell" mit FAQ.
|
||||||
|
|
||||||
- **Legal (resources/views/legal/):**
|
- **Legal (resources/views/legal/):**
|
||||||
- **datenschutz.blade.php:** Abschnitt "Zahlungen" (Stripe/PayPal: Keine Karten-Speicherung, GDPR: Löschung nach 10 Jahren; Consent für E-Mails). "Package-Daten (Limits) sind anonymisiert."
|
- **datenschutz.blade.php:** Abschnitt "Zahlungen" (Paddle: Keine Karten-Speicherung, GDPR: Löschung nach 10 Jahren; Consent für E-Mails). "Package-Daten (Limits) sind anonymisiert."
|
||||||
- **impressum.blade.php:** "Monetarisierung: Packages via Stripe/PayPal; USt-ID: ...; Support: support@fotospiel.de".
|
- **impressum.blade.php:** "Monetarisierung: Packages via Paddle; USt-ID: ...; Support: support@fotospiel.de".
|
||||||
- **Allgemein:** Datum "Aktualisiert: 2025-09-26 – Package-Modell"; Links zu Provider-Datenschutz.
|
- **Allgemein:** Datum "Aktualisiert: 2025-09-26 – Package-Modell"; Links zu Provider-Datenschutz.
|
||||||
|
|
||||||
**i18n:** Translations in lang/de/en (z.B. 'package.starter' → 'Starter-Paket').
|
**i18n:** Translations in lang/de/en (z.B. 'package.starter' → 'Starter-Paket').
|
||||||
@@ -160,7 +160,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
|
|||||||
## 6. Backend-Logik & API (Todo 6/7)
|
## 6. Backend-Logik & API (Todo 6/7)
|
||||||
- **Controllers:**
|
- **Controllers:**
|
||||||
- `PackagesController` (index: Liste mit Cache, show: Details, store: Intent für Kauf).
|
- `PackagesController` (index: Liste mit Cache, show: Details, store: Intent für Kauf).
|
||||||
- `PurchasesController` (intent: Erstelle Stripe-Session oder PayPal-Order basierend auf method; store: Nach Webhook).
|
- `PurchasesController` (intent: Erstelle Stripe-Session oder Paddle-Order basierend auf method; store: Nach Webhook).
|
||||||
- **Middleware:** `PackageMiddleware` (für Events: Check event_packages.used_photos < max_photos; für Tenant: used_events < max_events_per_year).
|
- **Middleware:** `PackageMiddleware` (für Events: Check event_packages.used_photos < max_photos; für Tenant: used_events < max_events_per_year).
|
||||||
- **Models:** `Package` (Relationships: hasMany EventPackage/TenantPackage), `EventPackage` (incrementUsedPhotos-Method), `TenantPackage` (isActive-Scope, Observer für Expiry: E-Mail + active=false).
|
- **Models:** `Package` (Relationships: hasMany EventPackage/TenantPackage), `EventPackage` (incrementUsedPhotos-Method), `TenantPackage` (isActive-Scope, Observer für Expiry: E-Mail + active=false).
|
||||||
- **API-Endpunkte (routes/api.php, tenant-group):**
|
- **API-Endpunkte (routes/api.php, tenant-group):**
|
||||||
@@ -175,7 +175,7 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
|
|||||||
|
|
||||||
## 7. Frontend-Anpassungen (Todo 8/9)
|
## 7. Frontend-Anpassungen (Todo 8/9)
|
||||||
- **Admin PWA (resources/js/admin/):**
|
- **Admin PWA (resources/js/admin/):**
|
||||||
- EventFormPage.tsx: Select('package_id') mit Details-Modal (Limits/Preis), Button 'Kaufen' → Stripe/PayPal-Integration (stripe.elements oder PayPal-Button).
|
- EventFormPage.tsx: Select('package_id') mit Details-Modal (Limits/Preis), Button 'Kaufen' → Paddle-Integration (stripe.elements oder Paddle-Button).
|
||||||
- Dashboard: Card 'Aktuelles Package' (Limits, Expiry, Upgrade-Button).
|
- Dashboard: Card 'Aktuelles Package' (Limits, Expiry, Upgrade-Button).
|
||||||
- SettingsPage.tsx: Reseller-Übersicht (used_events/Progress, Renew-Button).
|
- SettingsPage.tsx: Reseller-Übersicht (used_events/Progress, Renew-Button).
|
||||||
- Hooks: usePackageLimits (fetch /packages, check used_photos).
|
- Hooks: usePackageLimits (fetch /packages, check used_photos).
|
||||||
@@ -185,21 +185,21 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
|
|||||||
- Features: Watermark-Overlay if watermark_allowed; Branding-Logo if branding_allowed.
|
- Features: Watermark-Overlay if watermark_allowed; Branding-Logo if branding_allowed.
|
||||||
- Router: Guard für Limits (z.B. /upload → Check API).
|
- Router: Guard für Limits (z.B. /upload → Check API).
|
||||||
|
|
||||||
**Tech:** React Query für API-Calls, Stripe.js/PayPal-SDK in Components, i18n mit react-i18next.
|
**Tech:** React Query für API-Calls, Stripe.js/Paddle-SDK in Components, i18n mit react-i18next.
|
||||||
|
|
||||||
## 8. Billing-Integration (Todo 10)
|
## 8. Billing-Integration (Todo 10)
|
||||||
- **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + PayPal (Alternative: PHP SDK für Orders/Subscriptions).
|
- **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + Paddle (Alternative: PHP SDK für Orders/Subscriptions).
|
||||||
- **Flow:** Auswahl → Intent (Controller: if 'stripe' → Stripe::checkout()->sessions->create([...]); if 'paypal' → PayPal::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail).
|
- **Flow:** Auswahl → Intent (Controller: if 'stripe' → Stripe::checkout()->sessions->create([...]); if 'paypal' → Paddle::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail).
|
||||||
- **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PayPalWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase).
|
- **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PaddleWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase).
|
||||||
- **SDKs:** composer require stripe/stripe-php ^10.0, paypal/rest-api-sdk-php ^1.14; NPM: @stripe/stripe-js, @paypal/react-paypal-js.
|
- **SDKs:** composer require stripe/stripe-php ^10.0, paypal/rest-api-sdk-php ^1.14; NPM: @stripe/stripe-js, @paypal/react-paypal-js.
|
||||||
- **Free:** Kein Provider – direkt assign via API.
|
- **Free:** Kein Provider – direkt assign via API.
|
||||||
- **Refunds:** Action in PurchaseResource: Call Stripe::refunds->create oder PayPal::refunds, decrement Counters.
|
- **Refunds:** Action in PurchaseResource: Call Stripe::refunds->create oder Paddle::refunds, decrement Counters.
|
||||||
- **Env:** STRIPE_KEY/SECRET, PAYPAL_CLIENT_ID/SECRET, SANDBOX-Flags.
|
- **Env:** STRIPE_KEY/SECRET, PAYPAL_CLIENT_ID/SECRET, SANDBOX-Flags.
|
||||||
|
|
||||||
## 9. Tests (Todo 11)
|
## 9. Tests (Todo 11)
|
||||||
- **Unit/Feature:** Pest/PHPUnit: Test PackageSeeder, Migration (assert Tables exist), Controllers (mock Stripe/PayPal SDKs mit Stripe::mock(), test Intent/Webhook), Models (Package::find(1)->limits, TenantPackage::isActive), Middleware (assert denies if limit exceeded).
|
- **Unit/Feature:** Pest/PHPUnit: Test PackageSeeder, Migration (assert Tables exist), Controllers (mock Paddle SDKs mit Stripe::mock(), test Intent/Webhook), Models (Package::find(1)->limits, TenantPackage::isActive), Middleware (assert denies if limit exceeded).
|
||||||
- **E2E (Playwright):** Test Kauf-Flow (navigate /packages, select Starter, choose PayPal, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max).
|
- **E2E (Playwright):** Test Kauf-Flow (navigate /packages, select Starter, choose Paddle, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max).
|
||||||
- **Anpassungen:** RevenueCatWebhookTest → Stripe/PayPalWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422).
|
- **Anpassungen:** RevenueCatWebhookTest → PaddleWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422).
|
||||||
- **Coverage:** 80% für Billing/DB; Mock Providers für Isolation.
|
- **Coverage:** 80% für Billing/DB; Mock Providers für Isolation.
|
||||||
|
|
||||||
## 10. Deployment & Rollout (Todo 12)
|
## 10. Deployment & Rollout (Todo 12)
|
||||||
@@ -222,8 +222,8 @@ Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei E
|
|||||||
- **Support:** E-Mail-Templates (PurchaseMailable), FAQ in /support/packages, Onboarding-Tour post-Kauf.
|
- **Support:** E-Mail-Templates (PurchaseMailable), FAQ in /support/packages, Onboarding-Tour post-Kauf.
|
||||||
- **Performance:** Caching (Packages-Liste), Indexing (purchased_at), Queues für Webhooks (ProcessPurchaseJob).
|
- **Performance:** Caching (Packages-Liste), Indexing (purchased_at), Queues für Webhooks (ProcessPurchaseJob).
|
||||||
- **Edge-Cases:** Upgrade (prorate Preis, transfer Limits), Expiry (Observer + E-Mail), Offline-PWA (queued Käufe sync).
|
- **Edge-Cases:** Upgrade (prorate Preis, transfer Limits), Expiry (Observer + E-Mail), Offline-PWA (queued Käufe sync).
|
||||||
- **Dependencies:** Stripe/PayPal SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe).
|
- **Dependencies:** Paddle SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe).
|
||||||
- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Stripe/PayPal Test-Accounts.
|
- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Paddle Test-Accounts.
|
||||||
|
|
||||||
## 12. Todo-List (Status: Alle Planung completed)
|
## 12. Todo-List (Status: Alle Planung completed)
|
||||||
- [x] Analyse.
|
- [x] Analyse.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Billing and Payments
|
# Billing and Payments
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
The Fotospiel platform supports multiple payment providers for package purchases: Stripe for one-time and subscription payments, and PayPal for orders and subscriptions. Billing is handled through a freemium model with endcustomer event packages and reseller subscriptions. All payments are processed via API integrations, with webhooks for asynchronous updates.
|
The Fotospiel platform supports multiple payment providers for package purchases: Stripe for one-time and subscription payments, and Paddle for orders and subscriptions. Billing is handled through a freemium model with endcustomer event packages and reseller subscriptions. All payments are processed via API integrations, with webhooks for asynchronous updates.
|
||||||
|
|
||||||
## Stripe Integration
|
## Stripe Integration
|
||||||
- **One-time Payments**: Use Stripe Checkout for endcustomer event packages. Create PaymentIntent via `StripeController@createPaymentIntent`.
|
- **One-time Payments**: Use Stripe Checkout for endcustomer event packages. Create PaymentIntent via `StripeController@createPaymentIntent`.
|
||||||
@@ -19,13 +19,13 @@ The Fotospiel platform supports multiple payment providers for package purchases
|
|||||||
| `invoice.payment_failed` | Flags tenant for follow-up, sends alerts | `handleInvoicePaymentFailed` |
|
| `invoice.payment_failed` | Flags tenant for follow-up, sends alerts | `handleInvoicePaymentFailed` |
|
||||||
| `customer.subscription.deleted` | Finalises cancellation/downgrade | `handleSubscriptionDeleted` |
|
| `customer.subscription.deleted` | Finalises cancellation/downgrade | `handleSubscriptionDeleted` |
|
||||||
|
|
||||||
## PayPal Integration
|
## Paddle Integration
|
||||||
- **SDK**: Migrated to PayPal Server SDK v1.0+ (`paypal/paypal-server-sdk`). Uses Builder pattern for requests and Controllers for API calls.
|
- **SDK**: Migrated to Paddle Server SDK v1.0+ (`paypal/paypal-server-sdk`). Uses Builder pattern for requests and Controllers for API calls.
|
||||||
- **Orders (One-time Payments)**: Endcustomer event packages via Orders API. `PayPalController@createOrder` uses `OrderRequestBuilder` with `CheckoutPaymentIntent::CAPTURE`, custom_id for metadata (tenant_id, package_id, type). Capture in `@captureOrder` using `OrdersController->captureOrder`. DB creation in `processPurchaseFromOrder`.
|
- **Orders (One-time Payments)**: Endcustomer event packages via Orders API. `PaddleController@createOrder` uses `OrderRequestBuilder` with `CheckoutPaymentIntent::CAPTURE`, custom_id for metadata (tenant_id, package_id, type). Capture in `@captureOrder` using `OrdersController->captureOrder`. DB creation in `processPurchaseFromOrder`.
|
||||||
- **Subscriptions (Recurring Payments)**: Reseller subscriptions via Orders API with StoredPaymentSource for recurring setup (no dedicated SubscriptionsController in SDK). `PayPalController@createSubscription` uses `OrderRequestBuilder` with `StoredPaymentSource` (payment_initiator: CUSTOMER, payment_type: RECURRING, usage: FIRST), custom_id including plan_id. Initial order capture sets up subscription; subsequent billing via PayPal dashboard or webhooks. DB records created on initial capture, with expires_at for annual billing.
|
- **Subscriptions (Recurring Payments)**: Reseller subscriptions via Orders API with StoredPaymentSource for recurring setup (no dedicated SubscriptionsController in SDK). `PaddleController@createSubscription` uses `OrderRequestBuilder` with `StoredPaymentSource` (payment_initiator: CUSTOMER, payment_type: RECURRING, usage: FIRST), custom_id including plan_id. Initial order capture sets up subscription; subsequent billing via Paddle dashboard or webhooks. DB records created on initial capture, with expires_at for annual billing.
|
||||||
- **Differences**: One-time: Standard Order with payment_type ONE_TIME (default). Recurring: Order with StoredPaymentSource RECURRING to enable future charges without new approvals. plan_id stored in metadata for reference; no separate subscription ID from SDK.
|
- **Differences**: One-time: Standard Order with payment_type ONE_TIME (default). Recurring: Order with StoredPaymentSource RECURRING to enable future charges without new approvals. plan_id stored in metadata for reference; no separate subscription ID from SDK.
|
||||||
- **Client Setup**: OAuth2 Client Credentials flow. Builder: `PaypalServerSdkClientBuilder::init()->clientCredentialsAuthCredentials(ClientCredentialsAuthCredentialsBuilder::init(client_id, secret))->environment(Environment::SANDBOX/PRODUCTION)->build()`.
|
- **Client Setup**: OAuth2 Client Credentials flow. Builder: `PaypalServerSdkClientBuilder::init()->clientCredentialsAuthCredentials(ClientCredentialsAuthCredentialsBuilder::init(client_id, secret))->environment(Environment::SANDBOX/PRODUCTION)->build()`.
|
||||||
- **Webhooks**: `PayPalWebhookController@verify` handles events like `PAYMENT.CAPTURE.COMPLETED` (process initial/renewal purchase), `BILLING.SUBSCRIPTION.CANCELLED` or equivalent order events (deactivate package). Simplified signature verification (TODO: Implement `VerifyWebhookSignature`).
|
- **Webhooks**: `PaddleWebhookController@verify` handles events like `PAYMENT.CAPTURE.COMPLETED` (process initial/renewal purchase), `BILLING.SUBSCRIPTION.CANCELLED` or equivalent order events (deactivate package). Simplified signature verification (TODO: Implement `VerifyWebhookSignature`).
|
||||||
- **Webhook Matrix**:
|
- **Webhook Matrix**:
|
||||||
|
|
||||||
| Event | Purpose | Internal handler |
|
| Event | Purpose | Internal handler |
|
||||||
@@ -36,15 +36,15 @@ The Fotospiel platform supports multiple payment providers for package purchases
|
|||||||
| `BILLING.SUBSCRIPTION.SUSPENDED` | Pauses package benefits pending review | `handleSubscription` |
|
| `BILLING.SUBSCRIPTION.SUSPENDED` | Pauses package benefits pending review | `handleSubscription` |
|
||||||
- **Idempotency**: Check `provider_id` in `PackagePurchase` before creation. Transactions for DB safety.
|
- **Idempotency**: Check `provider_id` in `PackagePurchase` before creation. Transactions for DB safety.
|
||||||
- **Configuration**: Keys in `config/services.php` under `paypal`. Sandbox mode via `paypal.sandbox`.
|
- **Configuration**: Keys in `config/services.php` under `paypal`. Sandbox mode via `paypal.sandbox`.
|
||||||
- **Migration Notes**: Replaced old Checkout SDK (`PayPalCheckoutSdk`). Updated imports, requests (e.g., OrdersCreateRequest -> OrderRequestBuilder, Subscriptions -> StoredPaymentSource in Orders). Responses: Use `getStatusCode()` and `getResult()`. Tests mocked new structures. No breaking changes in auth or metadata handling; recurring flows now unified under Orders API.
|
- **Migration Notes**: Replaced old Checkout SDK (`PaddleCheckoutSdk`). Updated imports, requests (e.g., OrdersCreateRequest -> OrderRequestBuilder, Subscriptions -> StoredPaymentSource in Orders). Responses: Use `getStatusCode()` and `getResult()`. Tests mocked new structures. No breaking changes in auth or metadata handling; recurring flows now unified under Orders API.
|
||||||
|
|
||||||
## Database Models
|
## Database Models
|
||||||
- **PackagePurchase**: Records purchases with `tenant_id`, `package_id`, `provider_id` (Stripe PI/PayPal Order ID), `price`, `type` (endcustomer_event/reseller_subscription), `status` (completed/refunded), `metadata` (JSON with provider details).
|
- **PackagePurchase**: Records purchases with `tenant_id`, `package_id`, `provider_id` (Stripe PI/Paddle Order ID), `price`, `type` (endcustomer_event/reseller_subscription), `status` (completed/refunded), `metadata` (JSON with provider details).
|
||||||
- **TenantPackage**: Active packages with `tenant_id`, `package_id`, `price`, `purchased_at`, `expires_at`, `active` flag. Updated on purchase/cancellation.
|
- **TenantPackage**: Active packages with `tenant_id`, `package_id`, `price`, `purchased_at`, `expires_at`, `active` flag. Updated on purchase/cancellation.
|
||||||
- **Constraints**: `type` CHECK (endcustomer_event, reseller_subscription), `price` NOT NULL.
|
- **Constraints**: `type` CHECK (endcustomer_event, reseller_subscription), `price` NOT NULL.
|
||||||
|
|
||||||
## Flows
|
## Flows
|
||||||
1. **Purchase Initiation**: User selects package in PurchaseWizard. For free: direct assignment. Paid: Redirect to provider (Stripe Checkout or PayPal approve link).
|
1. **Purchase Initiation**: User selects package in PurchaseWizard. For free: direct assignment. Paid: Redirect to provider (Stripe Checkout or Paddle approve link).
|
||||||
2. **Completion**: Provider callback/webhook triggers capture/confirmation. Create `PackagePurchase` and `TenantPackage`. Update tenant `subscription_status` to 'active'.
|
2. **Completion**: Provider callback/webhook triggers capture/confirmation. Create `PackagePurchase` and `TenantPackage`. Update tenant `subscription_status` to 'active'.
|
||||||
3. **Cancellation/Refund**: Webhook updates status to 'cancelled', deactivates `TenantPackage`.
|
3. **Cancellation/Refund**: Webhook updates status to 'cancelled', deactivates `TenantPackage`.
|
||||||
4. **Trial**: First reseller subscription gets 14-day trial (`expires_at = now() + 14 days`).
|
4. **Trial**: First reseller subscription gets 14-day trial (`expires_at = now() + 14 days`).
|
||||||
@@ -63,5 +63,5 @@ The Fotospiel platform supports multiple payment providers for package purchases
|
|||||||
## Security & Compliance
|
## Security & Compliance
|
||||||
- GDPR: No PII in logs/metadata beyond necessary (tenant_id anonymous).
|
- GDPR: No PII in logs/metadata beyond necessary (tenant_id anonymous).
|
||||||
- Auth: Sanctum tokens for API, CSRF for web.
|
- Auth: Sanctum tokens for API, CSRF for web.
|
||||||
- Webhooks: IP whitelisting (PayPal IPs), signature verification.
|
- Webhooks: IP whitelisting (Paddle IPs), signature verification.
|
||||||
- Retention: Purchases retained per Privacy policy; update on changes.
|
- Retention: Purchases retained per Privacy policy; update on changes.
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Replace the legacy marketing checkout flow with a single `CheckoutController` that owns auth, payment, and confirmation steps.
|
- Replace the legacy marketing checkout flow with a single `CheckoutController` that owns auth, payment, and confirmation steps.
|
||||||
- Support Stripe card payments and PayPal orders with a consistent state machine that can be extended to other providers.
|
- Support Stripe card payments and Paddle orders with a consistent state machine that can be extended to other providers.
|
||||||
- Keep package activation logic idempotent and traceable while respecting GDPR (no new PII logging, no leaked tokens).
|
- Keep package activation logic idempotent and traceable while respecting GDPR (no new PII logging, no leaked tokens).
|
||||||
- Prepare the frontend wizard to drive the flow as an SPA without relying on server-side redirects.
|
- Prepare the frontend wizard to drive the flow as an SPA without relying on server-side redirects.
|
||||||
|
|
||||||
## Core Building Blocks
|
## Core Building Blocks
|
||||||
- **CheckoutSession model/table** keeps one purchase attempt per user + package. It stores provider choice, status, pricing snapshot, and external ids (Stripe intent, PayPal order, etc.).
|
- **CheckoutSession model/table** keeps one purchase attempt per user + package. It stores provider choice, status, pricing snapshot, and external ids (Stripe intent, Paddle order, etc.).
|
||||||
- **CheckoutPaymentService** orchestrates provider-specific actions (create intent/order, capture, sync metadata) and normalises responses for the wizard.
|
- **CheckoutPaymentService** orchestrates provider-specific actions (create intent/order, capture, sync metadata) and normalises responses for the wizard.
|
||||||
- **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds.
|
- **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds.
|
||||||
- **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA.
|
- **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA.
|
||||||
- **Webhooks** (Stripe, PayPal) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths.
|
- **Webhooks** (Stripe, Paddle) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths.
|
||||||
- **Feature Flag**: `config/checkout.php` exposes `CHECKOUT_WIZARD_ENABLED` and `CHECKOUT_WIZARD_FLAG` so the SPA flow can be toggled or gradual-rolled out during launch.
|
- **Feature Flag**: `config/checkout.php` exposes `CHECKOUT_WIZARD_ENABLED` and `CHECKOUT_WIZARD_FLAG` so the SPA flow can be toggled or gradual-rolled out during launch.
|
||||||
- **Operational**: Rotate JWT signing keys with `php artisan oauth:rotate-keys` (updates key folder per KID; remember to bump `OAUTH_JWT_KID`).
|
- **Operational**: Rotate JWT signing keys with `php artisan oauth:rotate-keys` (updates key folder per KID; remember to bump `OAUTH_JWT_KID`).
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ State constants live on `CheckoutSession` (`status` column, enum):
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `draft` | Session created, package locked in, no provider chosen. | `awaiting_payment_method`, `completed` (free), `cancelled` |
|
| `draft` | Session created, package locked in, no provider chosen. | `awaiting_payment_method`, `completed` (free), `cancelled` |
|
||||||
| `awaiting_payment_method` | Paid package; provider picked, waiting for client to initialise SDK. | `requires_customer_action`, `processing`, `cancelled` |
|
| `awaiting_payment_method` | Paid package; provider picked, waiting for client to initialise SDK. | `requires_customer_action`, `processing`, `cancelled` |
|
||||||
| `requires_customer_action` | Stripe 3DS, PayPal approval window open, or additional customer steps needed. | `processing`, `failed`, `cancelled` |
|
| `requires_customer_action` | Stripe 3DS, Paddle approval window open, or additional customer steps needed. | `processing`, `failed`, `cancelled` |
|
||||||
| `processing` | Provider reported success, backend validating / capturing / assigning. | `completed`, `failed` |
|
| `processing` | Provider reported success, backend validating / capturing / assigning. | `completed`, `failed` |
|
||||||
| `completed` | Checkout finished, package assigned, confirmation step unblocked. | none |
|
| `completed` | Checkout finished, package assigned, confirmation step unblocked. | none |
|
||||||
| `failed` | Provider declined or capture check failed; retain reason. | `awaiting_payment_method` (retry), `cancelled` |
|
| `failed` | Provider declined or capture check failed; retain reason. | `awaiting_payment_method` (retry), `cancelled` |
|
||||||
@@ -49,34 +49,34 @@ Create Eloquent model `App\Models\CheckoutSession` with casts for JSON columns a
|
|||||||
Group under `web.php` with `middleware(['auth', 'verified', 'locale', 'throttle:checkout'])`:
|
Group under `web.php` with `middleware(['auth', 'verified', 'locale', 'throttle:checkout'])`:
|
||||||
- `POST /checkout/session` (`CheckoutController@storeSession`): create or resume active session for selected package, return `{id, status, amount, package_snapshot}`.
|
- `POST /checkout/session` (`CheckoutController@storeSession`): create or resume active session for selected package, return `{id, status, amount, package_snapshot}`.
|
||||||
- `PATCH /checkout/session/{session}/package` (`updatePackage`): allow switching package before payment; resets provider-specific fields and status to `draft`.
|
- `PATCH /checkout/session/{session}/package` (`updatePackage`): allow switching package before payment; resets provider-specific fields and status to `draft`.
|
||||||
- `POST /checkout/session/{session}/provider` (`selectProvider`): set provider (`stripe` or `paypal`), transitions to `awaiting_payment_method` and returns provider configuration (publishable key, PayPal client id, feature flags).
|
- `POST /checkout/session/{session}/provider` (`selectProvider`): set provider (`stripe` or `paypal`), transitions to `awaiting_payment_method` and returns provider configuration (publishable key, Paddle client id, feature flags).
|
||||||
- `POST /checkout/session/{session}/stripe-intent` (`createStripeIntent`): idempotently create/update PaymentIntent with metadata (user, tenant, package, session id) and deliver `{client_secret, intent_id}`.
|
- `POST /checkout/session/{session}/stripe-intent` (`createStripeIntent`): idempotently create/update PaymentIntent with metadata (user, tenant, package, session id) and deliver `{client_secret, intent_id}`.
|
||||||
- `POST /checkout/session/{session}/stripe/confirm` (`confirmStripeIntent`): server-side verify PaymentIntent status (retrieve from Stripe) and transition to `processing` when `succeeded` or `requires_action`.
|
- `POST /checkout/session/{session}/stripe/confirm` (`confirmStripeIntent`): server-side verify PaymentIntent status (retrieve from Stripe) and transition to `processing` when `succeeded` or `requires_action`.
|
||||||
- `POST /checkout/session/{session}/paypal/order` (`createPayPalOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`.
|
- `POST /checkout/session/{session}/paypal/order` (`createPaddleOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`.
|
||||||
- `POST /checkout/session/{session}/paypal/capture` (`capturePayPalOrder`): capture order server-side, transition to `processing` if status `COMPLETED`.
|
- `POST /checkout/session/{session}/paypal/capture` (`capturePaddleOrder`): capture order server-side, transition to `processing` if status `COMPLETED`.
|
||||||
- `POST /checkout/session/{session}/free` (`activateFreePackage`): bypass providers, run assignment service, mark `completed`.
|
- `POST /checkout/session/{session}/free` (`activateFreePackage`): bypass providers, run assignment service, mark `completed`.
|
||||||
- `POST /checkout/session/{session}/complete` (`finalise`): provider-agnostic finishing hook used after `processing` to run `CheckoutAssignmentService`, persist `PackagePurchase`, queue mails, and respond with summary.
|
- `POST /checkout/session/{session}/complete` (`finalise`): provider-agnostic finishing hook used after `processing` to run `CheckoutAssignmentService`, persist `PackagePurchase`, queue mails, and respond with summary.
|
||||||
- `GET /checkout/session/{session}` (`show`): used by wizard polling to keep state in sync (status, provider display data, failure reasons).
|
- `GET /checkout/session/{session}` (`show`): used by wizard polling to keep state in sync (status, provider display data, failure reasons).
|
||||||
- `DELETE /checkout/session/{session}` (`cancel`): expire session, clean provider artefacts (cancel intent/order if applicable).
|
- `DELETE /checkout/session/{session}` (`cancel`): expire session, clean provider artefacts (cancel intent/order if applicable).
|
||||||
|
|
||||||
Stripe/PayPal routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified.
|
Paddle routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified.
|
||||||
|
|
||||||
### Services & Jobs
|
### Services & Jobs
|
||||||
- `CheckoutSessionService`: create/resume session, guard transitions, enforce TTL, and wrap DB transactions.
|
- `CheckoutSessionService`: create/resume session, guard transitions, enforce TTL, and wrap DB transactions.
|
||||||
- `CheckoutPaymentService`: entry point with methods `initialiseStripe`, `confirmStripe`, `initialisePayPal`, `capturePayPal`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, PayPal SDK) and persists external ids.
|
- `CheckoutPaymentService`: entry point with methods `initialiseStripe`, `confirmStripe`, `initialisePaddle`, `capturePaddle`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, Paddle SDK) and persists external ids.
|
||||||
- `CheckoutAssignmentService`: generates or reuses tenant, writes `TenantPackage`, `PackagePurchase`, updates user role/status, dispatches `Welcome` + purchase receipts, and emits domain events (`CheckoutCompleted`).
|
- `CheckoutAssignmentService`: generates or reuses tenant, writes `TenantPackage`, `PackagePurchase`, updates user role/status, dispatches `Welcome` + purchase receipts, and emits domain events (`CheckoutCompleted`).
|
||||||
- `SyncCheckoutFromWebhook` job: invoked by webhook controllers with provider payload, looks up `CheckoutSession` via provider id, runs assignment if needed, records failure states.
|
- `SyncCheckoutFromWebhook` job: invoked by webhook controllers with provider payload, looks up `CheckoutSession` via provider id, runs assignment if needed, records failure states.
|
||||||
|
|
||||||
### Webhook Alignment
|
### Webhook Alignment
|
||||||
- Update `StripeWebhookController` to resolve `CheckoutSession::where('stripe_payment_intent_id', intentId)`; when event indicates success, transition to `processing` (if not already), enqueue `SyncCheckoutFromWebhook` to finish assignment, and mark `completed` once done.
|
- Update `StripeWebhookController` to resolve `CheckoutSession::where('stripe_payment_intent_id', intentId)`; when event indicates success, transition to `processing` (if not already), enqueue `SyncCheckoutFromWebhook` to finish assignment, and mark `completed` once done.
|
||||||
- Update `PayPalWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`.
|
- Update `PaddleWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`.
|
||||||
- Webhooks become source-of-truth for delayed confirmations; wizard polls `GET /checkout/session/{id}` until `completed`.
|
- Webhooks become source-of-truth for delayed confirmations; wizard polls `GET /checkout/session/{id}` until `completed`.
|
||||||
|
|
||||||
### Validation & Security
|
### Validation & Security
|
||||||
- All mutating routes use CSRF tokens and `auth` guard (session-based). Add `EnsureCheckoutSessionOwner` middleware enforcing that the session belongs to `request->user()`.
|
- All mutating routes use CSRF tokens and `auth` guard (session-based). Add `EnsureCheckoutSessionOwner` middleware enforcing that the session belongs to `request->user()`.
|
||||||
- Input validation via dedicated Form Request classes (e.g., `StoreCheckoutSessionRequest`, `StripeIntentRequest`).
|
- Input validation via dedicated Form Request classes (e.g., `StoreCheckoutSessionRequest`, `StripeIntentRequest`).
|
||||||
- Provider responses are never logged raw; only store ids + safe metadata.
|
- Provider responses are never logged raw; only store ids + safe metadata.
|
||||||
- Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and PayPal orders.
|
- Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and Paddle orders.
|
||||||
|
|
||||||
## Frontend Touchpoints
|
## Frontend Touchpoints
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
|
|||||||
- On mount (and whenever package changes), call `/checkout/session` to create/resume session. Reset state if API reports new id.
|
- On mount (and whenever package changes), call `/checkout/session` to create/resume session. Reset state if API reports new id.
|
||||||
- Provider tabs call `selectProvider`. Stripe tab loads Stripe.js dynamically (import from `@stripe/stripe-js`) and mounts Elements once `client_secret` arrives.
|
- Provider tabs call `selectProvider`. Stripe tab loads Stripe.js dynamically (import from `@stripe/stripe-js`) and mounts Elements once `client_secret` arrives.
|
||||||
- Stripe flow: submit button triggers `stripe.confirmCardPayment(clientSecret)`, handle `requires_action`, then POST `/checkout/session/{id}/stripe/confirm`. On success, call `/checkout/session/{id}/complete` and advance to confirmation step.
|
- Stripe flow: submit button triggers `stripe.confirmCardPayment(clientSecret)`, handle `requires_action`, then POST `/checkout/session/{id}/stripe/confirm`. On success, call `/checkout/session/{id}/complete` and advance to confirmation step.
|
||||||
- PayPal flow: render PayPal Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`.
|
- Paddle flow: render Paddle Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`.
|
||||||
- Free packages skip provider selection; call `/checkout/session/{id}/free` and immediately advance.
|
- Free packages skip provider selection; call `/checkout/session/{id}/free` and immediately advance.
|
||||||
- Display status toasts based on `paymentStatus`; show inline error block when `failed` with `failure_reason` from API.
|
- Display status toasts based on `paymentStatus`; show inline error block when `failed` with `failure_reason` from API.
|
||||||
|
|
||||||
@@ -106,10 +106,10 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
|
|||||||
5. Once Stripe marks intent `succeeded`, backend transitions to `processing`, calls `CheckoutAssignmentService`, and marks `completed`.
|
5. Once Stripe marks intent `succeeded`, backend transitions to `processing`, calls `CheckoutAssignmentService`, and marks `completed`.
|
||||||
6. For reseller packages, Stripe subscription is created after assignment using configured price ids; resulting subscription id stored on session + tenant record.
|
6. For reseller packages, Stripe subscription is created after assignment using configured price ids; resulting subscription id stored on session + tenant record.
|
||||||
|
|
||||||
### PayPal (one-off and subscription)
|
### Paddle (one-off and subscription)
|
||||||
1. Session `draft` -> provider `paypal`.
|
1. Session `draft` -> provider `paypal`.
|
||||||
2. `createPayPalOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type.
|
2. `createPaddleOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type.
|
||||||
3. After approval, `capturePayPalOrder` verifies capture status; on `COMPLETED`, transitions to `processing`.
|
3. After approval, `capturePaddleOrder` verifies capture status; on `COMPLETED`, transitions to `processing`.
|
||||||
4. Assignment service runs, storing order id as `provider_id`. For subscriptions, capture handler stores subscription id and updates tenant subscription status.
|
4. Assignment service runs, storing order id as `provider_id`. For subscriptions, capture handler stores subscription id and updates tenant subscription status.
|
||||||
5. Webhooks handle late captures or cancellations (updates session -> `failed` or `cancelled`).
|
5. Webhooks handle late captures or cancellations (updates session -> `failed` or `cancelled`).
|
||||||
|
|
||||||
@@ -118,18 +118,18 @@ Stripe/PayPal routes remain under `routes/web.php` but call into new service cla
|
|||||||
|
|
||||||
## Migration Strategy
|
## Migration Strategy
|
||||||
1. **Phase 1** (current): land schema, services, new API routes; keep legacy MarketingController flow for fallback.
|
1. **Phase 1** (current): land schema, services, new API routes; keep legacy MarketingController flow for fallback.
|
||||||
2. **Phase 2**: wire the new wizard PaymentStep behind feature flag `checkout_v2` (in `.env` / config). Run internal QA with Stripe/PayPal sandbox.
|
2. **Phase 2**: wire the new wizard PaymentStep behind feature flag `checkout_v2` (in `.env` / config). Run internal QA with Paddle sandbox.
|
||||||
3. **Phase 3**: enable feature flag for production tenants, monitor Stripe/PayPal events, then delete legacy marketing payment paths and routes.
|
3. **Phase 3**: enable feature flag for production tenants, monitor Paddle events, then delete legacy marketing payment paths and routes.
|
||||||
4. **Phase 4**: tighten webhook logic and remove `MarketingController::checkout`, `::paypalCheckout`, `::stripeSubscription` once new flow is stable.
|
4. **Phase 4**: tighten webhook logic and remove `MarketingController::checkout`, `::paypalCheckout`, `::stripeSubscription` once new flow is stable.
|
||||||
|
|
||||||
## Testing & QA
|
## Testing & QA
|
||||||
- **Feature tests**: JSON endpoints for session lifecycle (create, provider select, intent creation, capture success/failure, free activation). Include multi-locale assertions.
|
- **Feature tests**: JSON endpoints for session lifecycle (create, provider select, intent creation, capture success/failure, free activation). Include multi-locale assertions.
|
||||||
- **Payment integration tests**: use Stripe + PayPal SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves.
|
- **Payment integration tests**: use Stripe + Paddle SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves.
|
||||||
- **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, PayPal approval, failure retry, free package shortcut, session resume after refresh.
|
- **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, Paddle approval, failure retry, free package shortcut, session resume after refresh.
|
||||||
- **Webhooks**: unit tests for mapping provider ids to sessions, plus job tests for idempotent assignment.
|
- **Webhooks**: unit tests for mapping provider ids to sessions, plus job tests for idempotent assignment.
|
||||||
- **Scheduler**: test `checkout:expire-sessions` to confirm PaymentIntents are cancelled and sessions flagged `cancelled`.
|
- **Scheduler**: test `checkout:expire-sessions` to confirm PaymentIntents are cancelled and sessions flagged `cancelled`.
|
||||||
|
|
||||||
## Open Questions / Follow-Ups
|
## Open Questions / Follow-Ups
|
||||||
- Map package records to Stripe price ids and PayPal plan ids (store on `packages` table or config?).
|
- Map package records to Stripe price ids and Paddle plan ids (store on `packages` table or config?).
|
||||||
- Confirm legal copy updates for new checkout experience before GA.
|
- Confirm legal copy updates for new checkout experience before GA.
|
||||||
- Align email templates (welcome, receipt) with new assignment service outputs.
|
- Align email templates (welcome, receipt) with new assignment service outputs.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Die App ersetzt das frühere Filament-basierte Tenant-Panel und fokussiert auf e
|
|||||||
|
|
||||||
## Aktuelle Highlights (Q4 2025)
|
## Aktuelle Highlights (Q4 2025)
|
||||||
- **Geführtes Onboarding**: `/event-admin/welcome/*` orchestriert den Welcome Flow (Hero → How-It-Works → Paketwahl → Zusammenfassung → Event Setup). Guarding erfolgt über `onboarding_completed_at`.
|
- **Geführtes Onboarding**: `/event-admin/welcome/*` orchestriert den Welcome Flow (Hero → How-It-Works → Paketwahl → Zusammenfassung → Event Setup). Guarding erfolgt über `onboarding_completed_at`.
|
||||||
- **Direkter Checkout**: Stripe & PayPal sind in die Paketwahl des Welcome Flows eingebettet; Fortschritt wird im Onboarding-Context persistiert.
|
- **Direkter Checkout**: Paddle sind in die Paketwahl des Welcome Flows eingebettet; Fortschritt wird im Onboarding-Context persistiert.
|
||||||
- **Filament Wizard**: Für Super-Admins existiert ein paralleler QR/Join-Token-Wizard in Filament (Token-Generierung, Layout-Downloads, Rotation).
|
- **Filament Wizard**: Für Super-Admins existiert ein paralleler QR/Join-Token-Wizard in Filament (Token-Generierung, Layout-Downloads, Rotation).
|
||||||
- **Join Tokens only**: Gäste erhalten ausschließlich join-token-basierte Links/QRs; slug-basierte URLs wurden deaktiviert. QR-Drucklayouts liegen unter `resources/views/pdf/join-tokens/*`.
|
- **Join Tokens only**: Gäste erhalten ausschließlich join-token-basierte Links/QRs; slug-basierte URLs wurden deaktiviert. QR-Drucklayouts liegen unter `resources/views/pdf/join-tokens/*`.
|
||||||
- **OAuth Alignment**: `VITE_OAUTH_CLIENT_ID` + `/event-admin/auth/callback` werden seedingseitig synchron gehalten; siehe `docs/prp/tenant-app-specs/api-usage.md`.
|
- **OAuth Alignment**: `VITE_OAUTH_CLIENT_ID` + `/event-admin/auth/callback` werden seedingseitig synchron gehalten; siehe `docs/prp/tenant-app-specs/api-usage.md`.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
|
|||||||
- **Galerie-Management**: Upload, Moderation, Feature-Flags, Analytics.
|
- **Galerie-Management**: Upload, Moderation, Feature-Flags, Analytics.
|
||||||
- **Mitglieder-Verwaltung**: Einladungen, Rollen, Zugriffskontrolle.
|
- **Mitglieder-Verwaltung**: Einladungen, Rollen, Zugriffskontrolle.
|
||||||
- **Tasks & Emotions**: Bibliothek, Zuweisung, Fortschritts-Tracking.
|
- **Tasks & Emotions**: Bibliothek, Zuweisung, Fortschritts-Tracking.
|
||||||
- **Abrechnung**: Paketübersicht, Stripe/PayPal Checkout, Ledger.
|
- **Abrechnung**: Paketübersicht, Paddle Checkout, Ledger.
|
||||||
- **Einstellungen**: Branding, Limits, Rechtstexte, Benachrichtigungen.
|
- **Einstellungen**: Branding, Limits, Rechtstexte, Benachrichtigungen.
|
||||||
- **Offline-Support**: App-Shell-Caching, Queueing von Mutationen, Sync bei Reconnect.
|
- **Offline-Support**: App-Shell-Caching, Queueing von Mutationen, Sync bei Reconnect.
|
||||||
- **Compliance**: Audit-Logging, GDPR-konforme Löschung, ETag-basierte Konfliktlösung.
|
- **Compliance**: Audit-Logging, GDPR-konforme Löschung, ETag-basierte Konfliktlösung.
|
||||||
@@ -25,7 +25,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
|
|||||||
- Routen `/event-admin/welcome/*` bilden den Flow.
|
- Routen `/event-admin/welcome/*` bilden den Flow.
|
||||||
- Filament stellt einen korrespondierenden Onboarding-Wizard (QR/Join-Token, Layout-Download) bereit; Abschluss setzt `onboarding_completed_at` serverseitig.
|
- Filament stellt einen korrespondierenden Onboarding-Wizard (QR/Join-Token, Layout-Download) bereit; Abschluss setzt `onboarding_completed_at` serverseitig.
|
||||||
- `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`).
|
- `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`).
|
||||||
- Paketwahl nutzt `GET /tenant/packages`; Stripe/PayPal-Fallbacks informieren bei fehlender Konfiguration.
|
- Paketwahl nutzt `GET /tenant/packages`; Paddle-Fallbacks informieren bei fehlender Konfiguration.
|
||||||
- Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde.
|
- Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde.
|
||||||
|
|
||||||
### Event Lifecycle
|
### Event Lifecycle
|
||||||
@@ -45,7 +45,7 @@ Die Admin-App muss folgende Kernfunktionen bereitstellen:
|
|||||||
|
|
||||||
### Billing & Checkout
|
### Billing & Checkout
|
||||||
- Pakete + Credit-Balance anzeigen.
|
- Pakete + Credit-Balance anzeigen.
|
||||||
- Stripe PaymentIntent & PayPal Smart Buttons; Fallback-Meldung bei fehlender Konfiguration.
|
- Stripe PaymentIntent & Paddle Smart Buttons; Fallback-Meldung bei fehlender Konfiguration.
|
||||||
- Ledger mit Historie (Paginierung, Filter).
|
- Ledger mit Historie (Paginierung, Filter).
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
@@ -83,7 +83,7 @@ Die App nutzt Endpunkte aus `docs/prp/03-api.md`.
|
|||||||
## Teststrategie
|
## Teststrategie
|
||||||
- **PHPUnit**: Feature-Tests für Auth-Guards (Tenant ohne Events → Welcome Flow).
|
- **PHPUnit**: Feature-Tests für Auth-Guards (Tenant ohne Events → Welcome Flow).
|
||||||
- **React Testing Library**: `TenantWelcomeLayout`, `PackageSelection`, `OnboardingGuard`, `OrderSummary`.
|
- **React Testing Library**: `TenantWelcomeLayout`, `PackageSelection`, `OnboardingGuard`, `OrderSummary`.
|
||||||
- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` deckt Login, Welcome → Packages → Summary → Event Setup ab; Erweiterung um Stripe/PayPal Happy Paths und Offline/Retry geplant.
|
- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` deckt Login, Welcome → Packages → Summary → Event Setup ab; Erweiterung um Paddle Happy Paths und Offline/Retry geplant.
|
||||||
- **Smoke Tests**: `npm run test:e2e` in CI mit optionalen Credentials (`E2E_TENANT_EMAIL`, `E2E_TENANT_PASSWORD`, Stripe/PayPal Keys).
|
- **Smoke Tests**: `npm run test:e2e` in CI mit optionalen Credentials (`E2E_TENANT_EMAIL`, `E2E_TENANT_PASSWORD`, Paddle Keys).
|
||||||
|
|
||||||
Für UI-Details siehe `docs/prp/tenant-app-specs/pages-ui.md`. Einstellungen werden in `docs/prp/tenant-app-specs/settings-config.md` beschrieben.
|
Für UI-Details siehe `docs/prp/tenant-app-specs/pages-ui.md`. Einstellungen werden in `docs/prp/tenant-app-specs/settings-config.md` beschrieben.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Hero | `/event-admin/welcome` | `TenantWelcomeLayout`, `WelcomeStepCard`, `EmblaCarousel` | CTA „Pakete entdecken“, sekundärer Link „Später entscheiden“ |
|
| Hero | `/event-admin/welcome` | `TenantWelcomeLayout`, `WelcomeStepCard`, `EmblaCarousel` | CTA „Pakete entdecken“, sekundärer Link „Später entscheiden“ |
|
||||||
| How It Works | `/event-admin/welcome` (Carousel Slide) | Icon Cards, Animated Gradients | 3 Vorteile (Fotos festhalten, Aufgaben, Gäste aktivieren) |
|
| How It Works | `/event-admin/welcome` (Carousel Slide) | Icon Cards, Animated Gradients | 3 Vorteile (Fotos festhalten, Aufgaben, Gäste aktivieren) |
|
||||||
| Paketwahl | `/event-admin/welcome/packages` | `PackageCard`, `PricingToggle`, `QueryPackageList` | Stripe/PayPal Pricing, Feature-Badges, Auswahl persistiert im Onboarding-Context |
|
| Paketwahl | `/event-admin/welcome/packages` | `PackageCard`, `PricingToggle`, `QueryPackageList` | Paddle Pricing, Feature-Badges, Auswahl persistiert im Onboarding-Context |
|
||||||
| Zusammenfassung | `/event-admin/welcome/summary` | `OrderSummaryCard`, Stripe Elements, PayPal Buttons | Hinweise bei fehlender Zahlungs-Konfiguration, CTA „Weiter zum Setup“ |
|
| Zusammenfassung | `/event-admin/welcome/summary` | `OrderSummaryCard`, Stripe Elements, Paddle Buttons | Hinweise bei fehlender Zahlungs-Konfiguration, CTA „Weiter zum Setup“ |
|
||||||
| Event Setup | `/event-admin/welcome/event` | `FirstEventForm`, `FormStepper`, Toasts | Formular (Name, Datum, Sprache, Feature-Toggles) + Abschluss CTA „Event erstellen“ |
|
| Event Setup | `/event-admin/welcome/event` | `FirstEventForm`, `FormStepper`, Toasts | Formular (Name, Datum, Sprache, Feature-Toggles) + Abschluss CTA „Event erstellen“ |
|
||||||
|
|
||||||
### Guards & Fortschritt
|
### Guards & Fortschritt
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
- **Fotos**: Moderationsgrid (Masonry), Filter (Neu, Genehmigt, Featured), Bulk-Aktionen in Sticky-Footer.
|
- **Fotos**: Moderationsgrid (Masonry), Filter (Neu, Genehmigt, Featured), Bulk-Aktionen in Sticky-Footer.
|
||||||
- **Tasks**: Tabs (Bibliothek, Zuweisungen), Drag-and-Drop (React Beautiful DnD), Inline-Editor für Aufgaben.
|
- **Tasks**: Tabs (Bibliothek, Zuweisungen), Drag-and-Drop (React Beautiful DnD), Inline-Editor für Aufgaben.
|
||||||
- **Einstellungen**: Accordion-Struktur (Branding, Legal Pages, Benachrichtigungen, Abrechnung). Preview-Panel für Farben und Logos.
|
- **Einstellungen**: Accordion-Struktur (Branding, Legal Pages, Benachrichtigungen, Abrechnung). Preview-Panel für Farben und Logos.
|
||||||
- **Abrechnung**: Kreditübersicht, Kauflog (infinite-scroll), Zahlungsoptionen (Stripe Karte, PayPal Checkout).
|
- **Abrechnung**: Kreditübersicht, Kauflog (infinite-scroll), Zahlungsoptionen (Stripe Karte, Paddle Checkout).
|
||||||
|
|
||||||
## Informationsarchitektur (aktuelle React-Router-Konfiguration)
|
## Informationsarchitektur (aktuelle React-Router-Konfiguration)
|
||||||
```
|
```
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
## Testabdeckung (UI)
|
## Testabdeckung (UI)
|
||||||
- **Jest/RTL**: `TenantWelcomeLayout`, `WelcomeStepCard`, `PackageSelection`, `OnboardingGuard`.
|
- **Jest/RTL**: `TenantWelcomeLayout`, `WelcomeStepCard`, `PackageSelection`, `OnboardingGuard`.
|
||||||
- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` (Login Guard, Welcome → Packages → Summary → Event Setup). Erweiterbar um Stripe/PayPal-Happy-Path sowie Offline-/Retry-Szenarien.
|
- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` (Login Guard, Welcome → Packages → Summary → Event Setup). Erweiterbar um Paddle-Happy-Path sowie Offline-/Retry-Szenarien.
|
||||||
|
|
||||||
## Legacy-Referenz (Framework7 Entwurf 2025-09)
|
## Legacy-Referenz (Framework7 Entwurf 2025-09)
|
||||||
Die ursprünglichen Wireframes für Framework7 (Toolbar, FAB, Infinite Scroll) sind weiterhin im Repo historisiert (`docs/prp/tenant-app-specs/pages-ui-legacy.md`). Für Vergleiche bei Regressionen oder Migrationen bitte dort nachsehen.
|
Die ursprünglichen Wireframes für Framework7 (Toolbar, FAB, Infinite Scroll) sind weiterhin im Repo historisiert (`docs/prp/tenant-app-specs/pages-ui-legacy.md`). Für Vergleiche bei Regressionen oder Migrationen bitte dort nachsehen.
|
||||||
|
|||||||
@@ -52,6 +52,6 @@
|
|||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
<text x="80" y="600" font-size="20" font-family="Inter, Arial, sans-serif" fill="#475569">
|
<text x="80" y="600" font-size="20" font-family="Inter, Arial, sans-serif" fill="#475569">
|
||||||
Stripe & PayPal Widgets erscheinen unterhalb der Karten, sobald Keys konfiguriert sind.
|
Stripe & Paddle Widgets erscheinen unterhalb der Karten, sobald Keys konfiguriert sind.
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -9,7 +9,7 @@
|
|||||||
Paket: Pro – 3 Events, 1000 Uploads
|
Paket: Pro – 3 Events, 1000 Uploads
|
||||||
</text>
|
</text>
|
||||||
<text x="40" y="180" font-size="22" font-family="Inter, Arial, sans-serif" fill="#cbd5f5">
|
<text x="40" y="180" font-size="22" font-family="Inter, Arial, sans-serif" fill="#cbd5f5">
|
||||||
Zahlungsart: Stripe oder PayPal
|
Zahlungsart: Stripe oder Paddle
|
||||||
</text>
|
</text>
|
||||||
<line x1="40" y1="220" x2="480" y2="220" stroke="#1f2a3d" stroke-width="2"/>
|
<line x1="40" y1="220" x2="480" y2="220" stroke="#1f2a3d" stroke-width="2"/>
|
||||||
<text x="40" y="280" font-size="24" font-family="Inter, Arial, sans-serif" fill="#60a5fa">
|
<text x="40" y="280" font-size="24" font-family="Inter, Arial, sans-serif" fill="#60a5fa">
|
||||||
@@ -44,10 +44,10 @@
|
|||||||
<g transform="translate(660,380)">
|
<g transform="translate(660,380)">
|
||||||
<rect width="480" height="220" rx="24" fill="#ffffff"/>
|
<rect width="480" height="220" rx="24" fill="#ffffff"/>
|
||||||
<text x="40" y="80" font-size="28" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#0f172a">
|
<text x="40" y="80" font-size="28" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#0f172a">
|
||||||
PayPal Smart Buttons
|
Paddle Checkout Links
|
||||||
</text>
|
</text>
|
||||||
<text x="40" y="130" font-size="20" font-family="Inter, Arial, sans-serif" fill="#1f2937">
|
<text x="40" y="130" font-size="20" font-family="Inter, Arial, sans-serif" fill="#1f2937">
|
||||||
Automatische Darstellung abhängig vom PayPal Client ID.
|
Automatische Darstellung abhängig von der Paddle-Konfiguration.
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -4,7 +4,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `01-welcome-hero.svg` | Hero-Screen mit CTA „Pakete entdecken“. |
|
| `01-welcome-hero.svg` | Hero-Screen mit CTA „Pakete entdecken“. |
|
||||||
| `02-how-it-works.svg` | Drei Highlight-Karten (Fotos, Aufgaben, Gäste). |
|
| `02-how-it-works.svg` | Drei Highlight-Karten (Fotos, Aufgaben, Gäste). |
|
||||||
| `03-package-selection.svg` | Paketübersicht inkl. Stripe/PayPal Modulen. |
|
| `03-package-selection.svg` | Paketübersicht inkl. Paddle Modulen. |
|
||||||
| `04-order-summary.svg` | Zusammenfassung mit Zahlungsoptionen. |
|
| `04-order-summary.svg` | Zusammenfassung mit Zahlungsoptionen. |
|
||||||
| `05-event-setup.svg` | Formular für das erste Event. |
|
| `05-event-setup.svg` | Formular für das erste Event. |
|
||||||
|
|
||||||
|
|||||||
28
docs/todo/paddle-catalog-sync.md
Normal file
28
docs/todo/paddle-catalog-sync.md
Normal 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).
|
||||||
14
docs/todo/paddle-migration.md
Normal file
14
docs/todo/paddle-migration.md
Normal 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).
|
||||||
@@ -43,7 +43,7 @@ Raise the baseline security posture across guest APIs, checkout, media storage,
|
|||||||
- `SEC-MS-04` — Storage health widget in Super Admin (Week 4).
|
- `SEC-MS-04` — Storage health widget in Super Admin (Week 4).
|
||||||
|
|
||||||
5. **Payments & Webhooks (Billing)**
|
5. **Payments & Webhooks (Billing)**
|
||||||
- Link Stripe/PayPal webhooks to checkout sessions with idempotency locks.
|
- Link Paddle webhooks to checkout sessions with idempotency locks.
|
||||||
- Add signature freshness validation + retry policies for provider outages.
|
- Add signature freshness validation + retry policies for provider outages.
|
||||||
- Pipe failed capture events into credit ledger audits and operator alerts.
|
- Pipe failed capture events into credit ledger audits and operator alerts.
|
||||||
- **Tickets**
|
- **Tickets**
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ Owner: Codex (handoff)
|
|||||||
- [x] Review PWA manifest/offline setup so die kombinierte Welcome+Management-Experience TWA-/Capacitor-ready ist (Manifest + `admin-sw.js` dokumentiert).
|
- [x] Review PWA manifest/offline setup so die kombinierte Welcome+Management-Experience TWA-/Capacitor-ready ist (Manifest + `admin-sw.js` dokumentiert).
|
||||||
- [x] Extend docs: PRP-Onboarding-Abschnitte aktualisiert, Screenshots unter `docs/screenshots/tenant-admin-onboarding/` ergänzt, Testscope notiert.
|
- [x] Extend docs: PRP-Onboarding-Abschnitte aktualisiert, Screenshots unter `docs/screenshots/tenant-admin-onboarding/` ergänzt, Testscope notiert.
|
||||||
- [x] Add automated coverage: Vitest + Testing Library für Welcome Landing, Dashboard-Guard und Checkout-Komponenten; `npm run test:unit` führt Suite aus.
|
- [x] Add automated coverage: Vitest + Testing Library für Welcome Landing, Dashboard-Guard und Checkout-Komponenten; `npm run test:unit` führt Suite aus.
|
||||||
- [x] Finalise direct checkout: Stripe/PayPal-Flows markieren Fortschritt, API-Mocks + Unit-Tests decken Erfolgs- und Fehlerpfade ab.
|
- [x] Finalise direct checkout: Paddle-Flows markieren Fortschritt, API-Mocks + Unit-Tests decken Erfolgs- und Fehlerpfade ab.
|
||||||
- [x] Lokalisierung ausbauen: Landing-, Packages-, Summary- und Event-Setup-Screens sind nun DE/EN übersetzt; Copy-Review für weitere Module (Tasks/Billing/Members) bleibt offen.
|
- [x] Lokalisierung ausbauen: Landing-, Packages-, Summary- und Event-Setup-Screens sind nun DE/EN übersetzt; Copy-Review für weitere Module (Tasks/Billing/Members) bleibt offen.
|
||||||
|
|
||||||
## Risks & Open Questions
|
## Risks & Open Questions
|
||||||
- Confirm checkout UX expectations (Stripe vs PayPal) before wiring package purchase into onboarding.
|
- Confirm checkout UX expectations (Stripe vs Paddle) before wiring package purchase into onboarding.
|
||||||
- Validate whether onboarding flow must be localized at launch; coordinate mit den neuen i18n JSONs und fehlenden Übersetzungen.
|
- Validate whether onboarding flow must be localized at launch; coordinate mit den neuen i18n JSONs und fehlenden Übersetzungen.
|
||||||
- Determine deprecation plan for fotospiel-tenant-app/tenant-admin-app once the merged flow ships.
|
- Determine deprecation plan for fotospiel-tenant-app/tenant-admin-app once the merged flow ships.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -10,14 +10,14 @@
|
|||||||
"contact": "Kontakt",
|
"contact": "Kontakt",
|
||||||
"vat_id": "Umsatzsteuer-ID: DE123456789",
|
"vat_id": "Umsatzsteuer-ID: DE123456789",
|
||||||
"monetization": "Monetarisierung",
|
"monetization": "Monetarisierung",
|
||||||
"monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de",
|
"monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. Preise exkl. MwSt. Support: support@fotospiel.de",
|
||||||
"register_court": "Registergericht: Amtsgericht Musterstadt",
|
"register_court": "Registergericht: Amtsgericht Musterstadt",
|
||||||
"commercial_register": "Handelsregister: HRB 12345",
|
"commercial_register": "Handelsregister: HRB 12345",
|
||||||
"datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.",
|
"datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.",
|
||||||
"responsible": "Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt",
|
"responsible": "Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt",
|
||||||
"data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.",
|
"data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.",
|
||||||
"payments": "Zahlungen und Packages",
|
"payments": "Zahlungen und Packages",
|
||||||
"payments_desc": "Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert – alle Daten werden verschlüsselt übertragen.",
|
"payments_desc": "Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Paddle verarbeitet.",
|
||||||
"data_retention": "Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.",
|
"data_retention": "Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.",
|
||||||
"rights": "Ihre Rechte: Auskunft, Löschung, Widerspruch.",
|
"rights": "Ihre Rechte: Auskunft, Löschung, Widerspruch.",
|
||||||
"cookies": "Cookies: Nur funktionale Cookies für die PWA.",
|
"cookies": "Cookies: Nur funktionale Cookies für die PWA.",
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
"data_security_desc": "Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).",
|
"data_security_desc": "Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).",
|
||||||
"and": "und",
|
"and": "und",
|
||||||
"stripe_privacy": "Stripe Datenschutz",
|
"stripe_privacy": "Stripe Datenschutz",
|
||||||
"paypal_privacy": "PayPal Datenschutz",
|
|
||||||
"agb": "Allgemeine Geschäftsbedingungen",
|
"agb": "Allgemeine Geschäftsbedingungen",
|
||||||
"effective_from": "Gültig seit {{date}}",
|
"effective_from": "Gültig seit {{date}}",
|
||||||
"version": "Version {{version}}"
|
"version": "Version {{version}}"
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
"faq_q3": "Was passiert bei Ablauf?",
|
"faq_q3": "Was passiert bei Ablauf?",
|
||||||
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
|
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
|
||||||
"faq_q4": "Zahlungssicher?",
|
"faq_q4": "Zahlungssicher?",
|
||||||
"faq_a4": "Ja, via Stripe oder PayPal – sicher und GDPR-konform.",
|
"faq_a4": "Ja, via Paddle – sicher und GDPR-konform.",
|
||||||
"final_cta": "Bereit für Ihr nächstes Event?",
|
"final_cta": "Bereit für Ihr nächstes Event?",
|
||||||
"contact_us": "Kontaktieren Sie uns",
|
"contact_us": "Kontaktieren Sie uns",
|
||||||
"feature_live_slideshow": "Live-Slideshow",
|
"feature_live_slideshow": "Live-Slideshow",
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
"billing_per_year": "pro Jahr",
|
"billing_per_year": "pro Jahr",
|
||||||
"more_features": "+{{count}} weitere Features",
|
"more_features": "+{{count}} weitere Features",
|
||||||
"feature_overview": "Feature-Überblick",
|
"feature_overview": "Feature-Überblick",
|
||||||
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.",
|
"order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.",
|
||||||
"features_label": "Features",
|
"features_label": "Features",
|
||||||
"feature_highlights": "Feature-Highlights",
|
"feature_highlights": "Feature-Highlights",
|
||||||
"more_details_tab": "Mehr Details",
|
"more_details_tab": "Mehr Details",
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
"faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.",
|
"faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.",
|
||||||
"faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.",
|
"faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.",
|
||||||
"faq_reseller_desc": "Reseller-Packages sind jährliche Abos für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.",
|
"faq_reseller_desc": "Reseller-Packages sind jährliche Abos für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.",
|
||||||
"faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Stripe oder PayPal abgewickelt. Ihre Daten sind GDPR-konform geschützt.",
|
"faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Paddle abgewickelt. Ihre Daten sind GDPR-konform geschützt.",
|
||||||
"testimonials": {
|
"testimonials": {
|
||||||
"anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.",
|
"anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.",
|
||||||
"max": "Als Event-Organisator liebe ich die Analytics und das einfache Branding. Super für Firmenevents!",
|
"max": "Als Event-Organisator liebe ich die Analytics und das einfache Branding. Super für Firmenevents!",
|
||||||
@@ -163,7 +163,9 @@
|
|||||||
"euro": "€"
|
"euro": "€"
|
||||||
},
|
},
|
||||||
"view_details": "Details ansehen",
|
"view_details": "Details ansehen",
|
||||||
"feature": "Feature"
|
"feature": "Feature",
|
||||||
|
"paddle_not_configured": "Dieses Package ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.",
|
||||||
|
"paddle_checkout_failed": "Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut."
|
||||||
},
|
},
|
||||||
"blog": {
|
"blog": {
|
||||||
"title": "Fotospiel - Blog",
|
"title": "Fotospiel - Blog",
|
||||||
@@ -280,22 +282,15 @@
|
|||||||
"no_account": "Kein Konto? Registrieren",
|
"no_account": "Kein Konto? Registrieren",
|
||||||
"manage_subscription": "Abo verwalten",
|
"manage_subscription": "Abo verwalten",
|
||||||
"stripe_dashboard": "Stripe-Dashboard",
|
"stripe_dashboard": "Stripe-Dashboard",
|
||||||
"paypal_dashboard": "PayPal-Dashboard",
|
|
||||||
"trial_activated": "Trial aktiviert für 14 Tage!"
|
"trial_activated": "Trial aktiviert für 14 Tage!"
|
||||||
},
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"title": "Zahlung",
|
"title": "Zahlung",
|
||||||
"card_details": "Kartendetails",
|
"card_details": "Kartendetails",
|
||||||
"stripe": "Kreditkarte",
|
"stripe": "Kreditkarte",
|
||||||
"paypal": "PayPal",
|
|
||||||
"submit_stripe": "Bezahlen mit Karte (:price)",
|
"submit_stripe": "Bezahlen mit Karte (:price)",
|
||||||
"submit_paypal": "Bezahlen mit PayPal (:price)",
|
|
||||||
"loading_stripe": "Lade Stripe...",
|
"loading_stripe": "Lade Stripe...",
|
||||||
"paypal_description": "Sichere Zahlung mit PayPal",
|
|
||||||
"switch_to_card": "Zur Kreditkarte wechseln",
|
"switch_to_card": "Zur Kreditkarte wechseln",
|
||||||
"paypal_create_error": "PayPal-Bestellung fehlgeschlagen",
|
|
||||||
"paypal_capture_error": "PayPal-Capture fehlgeschlagen",
|
|
||||||
"paypal_error": "PayPal-Zahlung fehlgeschlagen",
|
|
||||||
"stripe_error": "Stripe-Zahlung fehlgeschlagen",
|
"stripe_error": "Stripe-Zahlung fehlgeschlagen",
|
||||||
"confirm_error": "Bestätigung fehlgeschlagen",
|
"confirm_error": "Bestätigung fehlgeschlagen",
|
||||||
"complete_error": "Zahlung konnte nicht abgeschlossen werden"
|
"complete_error": "Zahlung konnte nicht abgeschlossen werden"
|
||||||
@@ -404,8 +399,19 @@
|
|||||||
"free_package_desc": "Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.",
|
"free_package_desc": "Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.",
|
||||||
"activate_package": "Paket aktivieren",
|
"activate_package": "Paket aktivieren",
|
||||||
"loading_payment": "Zahlungsdaten werden geladen...",
|
"loading_payment": "Zahlungsdaten werden geladen...",
|
||||||
"secure_payment_desc": "Sichere Zahlung mit Kreditkarte, Debitkarte oder SEPA-Lastschrift.",
|
"secure_payment_desc": "Sichere Zahlung über Paddle.",
|
||||||
"secure_paypal_desc": "Sichere Zahlung mit PayPal.",
|
"paddle_intro": "Wir öffnen den Paddle-Checkout direkt hier im Wizard, damit du im Ablauf bleibst.",
|
||||||
|
"paddle_preparing": "Paddle-Checkout wird vorbereitet…",
|
||||||
|
"paddle_overlay_ready": "Der Paddle-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.",
|
||||||
|
"paddle_ready": "Paddle-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.",
|
||||||
|
"paddle_error": "Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||||
|
"paddle_not_ready": "Der Paddle-Checkout ist noch nicht bereit. Bitte versuche es in einem Moment erneut.",
|
||||||
|
"paddle_not_configured": "Dieses Paket ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.",
|
||||||
|
"paddle_disclaimer": "Paddle wickelt Zahlungen als Merchant of Record ab. Steuern werden automatisch anhand deiner Rechnungsdaten berechnet.",
|
||||||
|
"pay_with_paddle": "Weiter mit Paddle",
|
||||||
|
"continue_after_payment": "Ich habe die Zahlung abgeschlossen",
|
||||||
|
"no_package_title": "Kein Paket ausgewählt",
|
||||||
|
"no_package_description": "Bitte wähle ein Paket, um zum Checkout zu gelangen.",
|
||||||
"payment_failed": "Zahlung fehlgeschlagen. ",
|
"payment_failed": "Zahlung fehlgeschlagen. ",
|
||||||
"error_card": "Kartenfehler aufgetreten.",
|
"error_card": "Kartenfehler aufgetreten.",
|
||||||
"error_validation": "Eingabedaten sind ungültig.",
|
"error_validation": "Eingabedaten sind ungültig.",
|
||||||
@@ -419,25 +425,18 @@
|
|||||||
"unexpected_status": "Unerwarteter Zahlungsstatus: {status}",
|
"unexpected_status": "Unerwarteter Zahlungsstatus: {status}",
|
||||||
"processing_btn": "Verarbeitung...",
|
"processing_btn": "Verarbeitung...",
|
||||||
"pay_now": "Jetzt bezahlen (€{price})",
|
"pay_now": "Jetzt bezahlen (€{price})",
|
||||||
"stripe_not_loaded": "Stripe ist nicht initialisiert. Bitte Seite neu laden.",
|
|
||||||
"network_error": "Netzwerkfehler beim Laden der Zahlungsdaten",
|
"network_error": "Netzwerkfehler beim Laden der Zahlungsdaten",
|
||||||
"payment_intent_error": "Fehler beim Laden der Zahlungsdaten",
|
|
||||||
"paypal_order_error": "PayPal-Bestellung konnte nicht erstellt werden. Bitte erneut versuchen.",
|
|
||||||
"paypal_capture_error": "PayPal-Abschluss fehlgeschlagen. Bitte erneut versuchen.",
|
|
||||||
"paypal_error": "PayPal meldete einen Fehler.",
|
|
||||||
"paypal_cancelled": "Sie haben die PayPal-Zahlung abgebrochen.",
|
|
||||||
"paypal_missing_plan": "Für dieses Paket fehlt die PayPal-Plan-Konfiguration. Bitte wählen Sie eine andere Zahlungsmethode.",
|
|
||||||
"auth_required": "Bitte melde dich an, um mit der Zahlung fortzufahren.",
|
"auth_required": "Bitte melde dich an, um mit der Zahlung fortzufahren.",
|
||||||
"status_loading": "Zahlungsvorbereitung läuft…",
|
"status_loading": "Zahlungsvorbereitung läuft…",
|
||||||
"status_ready": "Zahlungsformular bereit. Bitte gib deine Daten ein.",
|
"status_ready": "Zahlungsformular bereit. Bitte gib deine Daten ein.",
|
||||||
"status_processing": "Zahlung mit {{provider}} wird verarbeitet…",
|
"status_processing": "Zahlung mit {{provider}} wird verarbeitet…",
|
||||||
"status_success": "Zahlung bestätigt. Wir schließen den Kauf ab…",
|
"status_success": "Zahlung bestätigt. Wir schließen den Kauf ab…",
|
||||||
"status_info_title": "Zahlungsstatus",
|
"status_info_title": "Zahlungsstatus",
|
||||||
|
"status_processing_title": "Checkout wird geöffnet",
|
||||||
|
"status_ready_title": "Checkout geöffnet",
|
||||||
"status_error_title": "Zahlung fehlgeschlagen",
|
"status_error_title": "Zahlung fehlgeschlagen",
|
||||||
"status_success_title": "Zahlung abgeschlossen",
|
"status_success_title": "Zahlung abgeschlossen",
|
||||||
"status_retry": "Erneut versuchen",
|
"status_retry": "Erneut versuchen"
|
||||||
"method_stripe": "Kreditkarte (Stripe)",
|
|
||||||
"method_paypal": "PayPal"
|
|
||||||
},
|
},
|
||||||
"confirmation_step": {
|
"confirmation_step": {
|
||||||
"title": "Bestätigung",
|
"title": "Bestätigung",
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"vat_id": "VAT ID: DE123456789",
|
"vat_id": "VAT ID: DE123456789",
|
||||||
"monetization": "Monetization",
|
"monetization": "Monetization",
|
||||||
"monetization_desc": "We monetize through Packages (one-time purchases and subscriptions) via Stripe and PayPal. Prices excl. VAT. Support: support@fotospiel.de",
|
"monetization_desc": "We monetize through Packages (one-time purchases and subscriptions) via Paddle. Prices excl. VAT. Support: support@fotospiel.de",
|
||||||
"register_court": "Register Court: District Court Musterstadt",
|
"register_court": "Register Court: District Court Musterstadt",
|
||||||
"commercial_register": "Commercial Register: HRB 12345",
|
"commercial_register": "Commercial Register: HRB 12345",
|
||||||
"datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.",
|
"datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.",
|
||||||
"responsible": "Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt",
|
"responsible": "Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt",
|
||||||
"data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.",
|
"data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.",
|
||||||
"payments": "Payments and Packages",
|
"payments": "Payments and Packages",
|
||||||
"payments_desc": "We process payments for Packages via Stripe and PayPal. Card information is not stored – all data is transmitted encrypted. See Stripe Privacy and PayPal Privacy.",
|
"payments_desc": "We process payments for Packages via Paddle. Payment data is handled securely and encrypted by Paddle as the merchant of record.",
|
||||||
"data_retention": "Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.",
|
"data_retention": "Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.",
|
||||||
"rights": "Your rights: Information, deletion, objection. Contact us under Contact.",
|
"rights": "Your rights: Information, deletion, objection. Contact us under Contact.",
|
||||||
"cookies": "Cookies: Only functional cookies for the PWA.",
|
"cookies": "Cookies: Only functional cookies for the PWA.",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
"faq_q3": "What happens when it expires?",
|
"faq_q3": "What happens when it expires?",
|
||||||
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.",
|
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.",
|
||||||
"faq_q4": "Payment secure?",
|
"faq_q4": "Payment secure?",
|
||||||
"faq_a4": "Yes, via Stripe or PayPal – secure and GDPR compliant.",
|
"faq_a4": "Yes, via Paddle – secure and GDPR compliant.",
|
||||||
"final_cta": "Ready for your next event?",
|
"final_cta": "Ready for your next event?",
|
||||||
"contact_us": "Contact Us",
|
"contact_us": "Contact Us",
|
||||||
"feature_live_slideshow": "Live Slideshow",
|
"feature_live_slideshow": "Live Slideshow",
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
"billing_per_year": "per year",
|
"billing_per_year": "per year",
|
||||||
"more_features": "+{{count}} more features",
|
"more_features": "+{{count}} more features",
|
||||||
"feature_overview": "Feature overview",
|
"feature_overview": "Feature overview",
|
||||||
"order_hint": "Launch instantly – secure Stripe or PayPal checkout, no hidden fees.",
|
"order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.",
|
||||||
"features_label": "Features",
|
"features_label": "Features",
|
||||||
"feature_highlights": "Feature Highlights",
|
"feature_highlights": "Feature Highlights",
|
||||||
"more_details_tab": "More Details",
|
"more_details_tab": "More Details",
|
||||||
@@ -149,7 +149,9 @@
|
|||||||
},
|
},
|
||||||
"currency": {
|
"currency": {
|
||||||
"euro": "€"
|
"euro": "€"
|
||||||
}
|
},
|
||||||
|
"paddle_not_configured": "This package is not ready for Paddle checkout. Please contact support.",
|
||||||
|
"paddle_checkout_failed": "We could not start the Paddle checkout. Please try again later."
|
||||||
},
|
},
|
||||||
"blog": {
|
"blog": {
|
||||||
"title": "Fotospiel - Blog",
|
"title": "Fotospiel - Blog",
|
||||||
@@ -266,22 +268,15 @@
|
|||||||
"no_account": "No Account? Register",
|
"no_account": "No Account? Register",
|
||||||
"manage_subscription": "Manage Subscription",
|
"manage_subscription": "Manage Subscription",
|
||||||
"stripe_dashboard": "Stripe Dashboard",
|
"stripe_dashboard": "Stripe Dashboard",
|
||||||
"paypal_dashboard": "PayPal Dashboard",
|
|
||||||
"trial_activated": "Trial activated for 14 days!"
|
"trial_activated": "Trial activated for 14 days!"
|
||||||
},
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"title": "Payment",
|
"title": "Payment",
|
||||||
"card_details": "Card Details",
|
"card_details": "Card Details",
|
||||||
"stripe": "Credit Card",
|
"stripe": "Credit Card",
|
||||||
"paypal": "PayPal",
|
|
||||||
"submit_stripe": "Pay with Card (:price)",
|
"submit_stripe": "Pay with Card (:price)",
|
||||||
"submit_paypal": "Pay with PayPal (:price)",
|
|
||||||
"loading_stripe": "Loading Stripe...",
|
"loading_stripe": "Loading Stripe...",
|
||||||
"paypal_description": "Secure payment with PayPal",
|
|
||||||
"switch_to_card": "Switch to Credit Card",
|
"switch_to_card": "Switch to Credit Card",
|
||||||
"paypal_create_error": "PayPal order creation failed",
|
|
||||||
"paypal_capture_error": "PayPal capture failed",
|
|
||||||
"paypal_error": "PayPal payment failed",
|
|
||||||
"stripe_error": "Stripe payment failed",
|
"stripe_error": "Stripe payment failed",
|
||||||
"confirm_error": "Confirmation failed",
|
"confirm_error": "Confirmation failed",
|
||||||
"complete_error": "Payment could not be completed"
|
"complete_error": "Payment could not be completed"
|
||||||
@@ -398,8 +393,19 @@
|
|||||||
"free_package_desc": "This package is free. We activate it directly after confirmation.",
|
"free_package_desc": "This package is free. We activate it directly after confirmation.",
|
||||||
"activate_package": "Activate Package",
|
"activate_package": "Activate Package",
|
||||||
"loading_payment": "Payment data is loading...",
|
"loading_payment": "Payment data is loading...",
|
||||||
"secure_payment_desc": "Secure payment with credit card, debit card or SEPA direct debit.",
|
"secure_payment_desc": "Secure payment with Paddle.",
|
||||||
"secure_paypal_desc": "Pay securely with PayPal.",
|
"paddle_intro": "We open Paddle's secure checkout directly inside this wizard so you never leave the flow.",
|
||||||
|
"paddle_preparing": "Preparing Paddle checkout…",
|
||||||
|
"paddle_overlay_ready": "Paddle checkout is running in a secure overlay. Complete the payment there and then continue here.",
|
||||||
|
"paddle_ready": "Paddle checkout opened in a new tab. Complete the payment and then continue here.",
|
||||||
|
"paddle_error": "We could not start the Paddle checkout. Please try again.",
|
||||||
|
"paddle_not_ready": "Paddle checkout is not ready yet. Please try again in a moment.",
|
||||||
|
"paddle_not_configured": "This package is not ready for Paddle checkout. Please contact support.",
|
||||||
|
"paddle_disclaimer": "Paddle processes payments as merchant of record. Taxes are calculated automatically based on your billing details.",
|
||||||
|
"pay_with_paddle": "Continue with Paddle",
|
||||||
|
"continue_after_payment": "I completed the payment",
|
||||||
|
"no_package_title": "No package selected",
|
||||||
|
"no_package_description": "Please choose a package to continue to checkout.",
|
||||||
"payment_failed": "Payment failed. ",
|
"payment_failed": "Payment failed. ",
|
||||||
"error_card": "Card error occurred.",
|
"error_card": "Card error occurred.",
|
||||||
"error_validation": "Input data is invalid.",
|
"error_validation": "Input data is invalid.",
|
||||||
@@ -413,25 +419,18 @@
|
|||||||
"unexpected_status": "Unexpected payment status: {status}",
|
"unexpected_status": "Unexpected payment status: {status}",
|
||||||
"processing_btn": "Processing...",
|
"processing_btn": "Processing...",
|
||||||
"pay_now": "Pay Now (${price})",
|
"pay_now": "Pay Now (${price})",
|
||||||
"stripe_not_loaded": "Stripe is not initialized. Please reload the page.",
|
|
||||||
"network_error": "Network error loading payment data",
|
"network_error": "Network error loading payment data",
|
||||||
"payment_intent_error": "Error loading payment data",
|
|
||||||
"paypal_order_error": "Could not create the PayPal order. Please try again.",
|
|
||||||
"paypal_capture_error": "PayPal capture failed. Please try again.",
|
|
||||||
"paypal_error": "PayPal reported an error.",
|
|
||||||
"paypal_cancelled": "You cancelled the PayPal payment.",
|
|
||||||
"paypal_missing_plan": "Missing PayPal plan configuration for this package. Please choose another payment method.",
|
|
||||||
"auth_required": "Please log in to continue to payment.",
|
"auth_required": "Please log in to continue to payment.",
|
||||||
"status_loading": "Preparing secure payment data…",
|
"status_loading": "Preparing secure payment data…",
|
||||||
"status_ready": "Payment form ready. Enter your details to continue.",
|
"status_ready": "Payment form ready. Enter your details to continue.",
|
||||||
"status_processing": "Processing payment with {{provider}}…",
|
"status_processing": "Processing payment with {{provider}}…",
|
||||||
"status_success": "Payment confirmed. Finalising your order…",
|
"status_success": "Payment confirmed. Finalising your order…",
|
||||||
"status_info_title": "Payment status",
|
"status_info_title": "Payment status",
|
||||||
|
"status_processing_title": "We are opening the checkout",
|
||||||
|
"status_ready_title": "Checkout opened",
|
||||||
"status_error_title": "Payment failed",
|
"status_error_title": "Payment failed",
|
||||||
"status_success_title": "Payment completed",
|
"status_success_title": "Payment completed",
|
||||||
"status_retry": "Retry",
|
"status_retry": "Retry"
|
||||||
"method_stripe": "Credit Card (Stripe)",
|
|
||||||
"method_paypal": "PayPal"
|
|
||||||
},
|
},
|
||||||
"confirmation_step": {
|
"confirmation_step": {
|
||||||
"title": "Confirmation",
|
"title": "Confirmation",
|
||||||
|
|||||||
@@ -119,6 +119,20 @@ export type CreditBalance = {
|
|||||||
free_event_granted_at?: string | null;
|
free_event_granted_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PaddleTransactionSummary = {
|
||||||
|
id: string | null;
|
||||||
|
status: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
checkout_id: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
receipt_url?: string | null;
|
||||||
|
grand_total?: number | null;
|
||||||
|
tax?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreditLedgerEntry = {
|
export type CreditLedgerEntry = {
|
||||||
id: number;
|
id: number;
|
||||||
delta: number;
|
delta: number;
|
||||||
@@ -444,6 +458,25 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary {
|
||||||
|
const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total);
|
||||||
|
const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof entry.id === 'string' ? entry.id : entry.id ? String(entry.id) : null,
|
||||||
|
status: entry.status ?? null,
|
||||||
|
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
|
||||||
|
currency: entry.currency ?? entry.currency_code ?? 'EUR',
|
||||||
|
origin: entry.origin ?? null,
|
||||||
|
checkout_id: entry.checkout_id ?? (entry.details?.checkout_id ?? null),
|
||||||
|
created_at: entry.created_at ?? null,
|
||||||
|
updated_at: entry.updated_at ?? null,
|
||||||
|
receipt_url: entry.receipt_url ?? entry.invoice_url ?? null,
|
||||||
|
grand_total: entry.grand_total !== undefined && entry.grand_total !== null ? Number(entry.grand_total) : null,
|
||||||
|
tax: taxValue !== undefined && taxValue !== null ? Number(taxValue) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTask(task: JsonValue): TenantTask {
|
function normalizeTask(task: JsonValue): TenantTask {
|
||||||
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
|
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
|
||||||
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
|
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
|
||||||
@@ -813,6 +846,35 @@ export async function getTenantPackagesOverview(): Promise<{
|
|||||||
return { packages, activePackage };
|
return { packages, activePackage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||||
|
data: PaddleTransactionSummary[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
|
||||||
|
const response = await authorizedFetch(`/api/v1/tenant/billing/transactions${query}`);
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return { data: [], nextCursor: null, hasMore: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await safeJson(response);
|
||||||
|
console.error('[API] Failed to load Paddle transactions', response.status, payload);
|
||||||
|
throw new Error('Failed to load Paddle transactions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await safeJson(response) ?? {};
|
||||||
|
const entries = Array.isArray(payload.data) ? payload.data : [];
|
||||||
|
const meta = payload.meta ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: entries.map(normalizePaddleTransaction),
|
||||||
|
nextCursor: typeof meta.next === 'string' ? meta.next : null,
|
||||||
|
hasMore: Boolean(meta.has_more),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCreditBalance(): Promise<CreditBalance> {
|
export async function getCreditBalance(): Promise<CreditBalance> {
|
||||||
const response = await authorizedFetch('/api/v1/tenant/credits/balance');
|
const response = await authorizedFetch('/api/v1/tenant/credits/balance');
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
@@ -868,17 +930,17 @@ export async function createTenantPackagePaymentIntent(packageId: number): Promi
|
|||||||
export async function completeTenantPackagePurchase(params: {
|
export async function completeTenantPackagePurchase(params: {
|
||||||
packageId: number;
|
packageId: number;
|
||||||
paymentMethodId?: string;
|
paymentMethodId?: string;
|
||||||
paypalOrderId?: string;
|
paddleTransactionId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { packageId, paymentMethodId, paypalOrderId } = params;
|
const { packageId, paymentMethodId, paddleTransactionId } = params;
|
||||||
const payload: Record<string, unknown> = { package_id: packageId };
|
const payload: Record<string, unknown> = { package_id: packageId };
|
||||||
|
|
||||||
if (paymentMethodId) {
|
if (paymentMethodId) {
|
||||||
payload.payment_method_id = paymentMethodId;
|
payload.payment_method_id = paymentMethodId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paypalOrderId) {
|
if (paddleTransactionId) {
|
||||||
payload.paypal_order_id = paypalOrderId;
|
payload.paddle_transaction_id = paddleTransactionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await authorizedFetch('/api/v1/tenant/packages/complete', {
|
const response = await authorizedFetch('/api/v1/tenant/packages/complete', {
|
||||||
@@ -904,8 +966,8 @@ export async function assignFreeTenantPackage(packageId: number): Promise<void>
|
|||||||
await jsonOrThrow(response, 'Failed to assign free package');
|
await jsonOrThrow(response, 'Failed to assign free package');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTenantPayPalOrder(packageId: number): Promise<string> {
|
export async function createTenantPaddleCheckout(packageId: number): Promise<{ checkout_url: string }> {
|
||||||
const response = await authorizedFetch('/api/v1/tenant/packages/paypal-create', {
|
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -913,24 +975,12 @@ export async function createTenantPayPalOrder(packageId: number): Promise<string
|
|||||||
body: JSON.stringify({ package_id: packageId }),
|
body: JSON.stringify({ package_id: packageId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await jsonOrThrow<{ orderID: string }>(response, 'Failed to create PayPal order');
|
const data = await jsonOrThrow<{ checkout_url: string }>(response, 'Failed to create Paddle checkout');
|
||||||
if (!data.orderID) {
|
if (!data.checkout_url) {
|
||||||
throw new Error('Missing PayPal order ID');
|
throw new Error('Missing Paddle checkout URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.orderID;
|
return { checkout_url: data.checkout_url };
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureTenantPayPalOrder(orderId: string): Promise<void> {
|
|
||||||
const response = await authorizedFetch('/api/v1/tenant/packages/paypal-capture', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ order_id: orderId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await jsonOrThrow(response, 'Failed to capture PayPal order');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recordCreditPurchase(payload: {
|
export async function recordCreditPurchase(payload: {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface StoredTokens {
|
|||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
clientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
@@ -83,13 +84,14 @@ export function loadTokens(): StoredTokens | null {
|
|||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveTokens(response: TokenResponse): StoredTokens {
|
export function saveTokens(response: TokenResponse, clientId: string = getClientId()): StoredTokens {
|
||||||
const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
|
const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
|
||||||
const stored: StoredTokens = {
|
const stored: StoredTokens = {
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token,
|
refreshToken: response.refresh_token,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
scope: response.scope,
|
scope: response.scope,
|
||||||
|
clientId,
|
||||||
};
|
};
|
||||||
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
|
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
|
||||||
return stored;
|
return stored;
|
||||||
@@ -110,19 +112,21 @@ export async function ensureAccessToken(): Promise<string> {
|
|||||||
return tokens.accessToken;
|
return tokens.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
return refreshAccessToken(tokens.refreshToken);
|
return refreshAccessToken(tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAccessToken(refreshToken: string): Promise<string> {
|
async function refreshAccessToken(tokens: StoredTokens): Promise<string> {
|
||||||
if (!refreshToken) {
|
const clientId = tokens.clientId ?? getClientId();
|
||||||
|
|
||||||
|
if (!tokens.refreshToken) {
|
||||||
notifyAuthFailure();
|
notifyAuthFailure();
|
||||||
throw new AuthError('unauthenticated', 'Missing refresh token');
|
throw new AuthError('unauthenticated', 'Missing refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: tokens.refreshToken,
|
||||||
client_id: getClientId(),
|
client_id: clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(TOKEN_ENDPOINT, {
|
const response = await fetch(TOKEN_ENDPOINT, {
|
||||||
@@ -138,7 +142,7 @@ async function refreshAccessToken(refreshToken: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as TokenResponse;
|
const data = (await response.json()) as TokenResponse;
|
||||||
const stored = saveTokens(data);
|
const stored = saveTokens(data, clientId);
|
||||||
return stored.accessToken;
|
return stored.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,10 +219,12 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
|
|||||||
localStorage.removeItem(CODE_VERIFIER_KEY);
|
localStorage.removeItem(CODE_VERIFIER_KEY);
|
||||||
localStorage.removeItem(STATE_KEY);
|
localStorage.removeItem(STATE_KEY);
|
||||||
|
|
||||||
|
const clientId = getClientId();
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
client_id: getClientId(),
|
client_id: clientId,
|
||||||
redirect_uri: buildRedirectUri(),
|
redirect_uri: buildRedirectUri(),
|
||||||
code_verifier: verifier,
|
code_verifier: verifier,
|
||||||
});
|
});
|
||||||
@@ -237,7 +243,7 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as TokenResponse;
|
const data = (await response.json()) as TokenResponse;
|
||||||
saveTokens(data);
|
saveTokens(data, clientId);
|
||||||
|
|
||||||
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
||||||
if (redirectTarget) {
|
if (redirectTarget) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
|
|||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
clientId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CLIENTS: Record<string, string> = {
|
const CLIENTS: Record<string, string> = {
|
||||||
@@ -29,11 +30,9 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
|
|||||||
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
|
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
|
||||||
window.location.assign('/event-admin/dashboard');
|
window.location.assign('/event-admin/dashboard');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error('[DevAuth] Failed to login', error.message);
|
console.error('[DevAuth] Failed to login', message);
|
||||||
} else {
|
throw error instanceof Error ? error : new Error(message);
|
||||||
console.error('[DevAuth] Failed to login', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +83,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
|
|||||||
refreshToken: body.refresh_token,
|
refreshToken: body.refresh_token,
|
||||||
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
||||||
scope: body.scope,
|
scope: body.scope,
|
||||||
|
clientId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +126,11 @@ function requestAuthorization(url: string): Promise<URL> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
|
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
|
||||||
if (xhr.status >= 200 && xhr.status < 400 && responseUrl) {
|
if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) {
|
||||||
resolve(new URL(responseUrl, window.location.origin));
|
if (responseUrl) {
|
||||||
return;
|
resolve(new URL(responseUrl, window.location.origin));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(new Error(`Authorize failed with ${xhr.status}`));
|
reject(new Error(`Authorize failed with ${xhr.status}`));
|
||||||
|
|||||||
@@ -45,6 +45,27 @@
|
|||||||
"available": "Verfügbar",
|
"available": "Verfügbar",
|
||||||
"expires": "Läuft ab"
|
"expires": "Läuft ab"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"title": "Paddle-Transaktionen",
|
||||||
|
"description": "Neueste Paddle-Transaktionen für diesen Tenant.",
|
||||||
|
"empty": "Noch keine Paddle-Transaktionen.",
|
||||||
|
"labels": {
|
||||||
|
"transactionId": "Transaktion {{id}}",
|
||||||
|
"checkoutId": "Checkout-ID: {{id}}",
|
||||||
|
"origin": "Herkunft: {{origin}}",
|
||||||
|
"receipt": "Beleg ansehen",
|
||||||
|
"tax": "Steuer: {{value}}"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"completed": "Abgeschlossen",
|
||||||
|
"processing": "Verarbeitung",
|
||||||
|
"failed": "Fehlgeschlagen",
|
||||||
|
"cancelled": "Storniert",
|
||||||
|
"unknown": "Unbekannt"
|
||||||
|
},
|
||||||
|
"loadMore": "Weitere Transaktionen laden",
|
||||||
|
"loadingMore": "Laden…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -149,8 +170,7 @@
|
|||||||
"high": "Hoch",
|
"high": "Hoch",
|
||||||
"urgent": "Dringend"
|
"urgent": "Dringend"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
,
|
|
||||||
"collections": {
|
"collections": {
|
||||||
"title": "Aufgabenvorlagen",
|
"title": "Aufgabenvorlagen",
|
||||||
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",
|
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",
|
||||||
@@ -244,58 +264,56 @@
|
|||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"submit": "Emotion speichern"
|
"submit": "Emotion speichern"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
,
|
"management": {
|
||||||
"management": {
|
"billing": {
|
||||||
"billing": {
|
"title": "Pakete & Abrechnung",
|
||||||
"title": "Pakete & Abrechnung",
|
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
||||||
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
"actions": {
|
||||||
"actions": {
|
"refresh": "Aktualisieren",
|
||||||
"refresh": "Aktualisieren",
|
"exportCsv": "Export als CSV"
|
||||||
"exportCsv": "Export als CSV"
|
},
|
||||||
},
|
"errors": {
|
||||||
"errors": {
|
"load": "Paketdaten konnten nicht geladen werden.",
|
||||||
"load": "Paketdaten konnten nicht geladen werden.",
|
"more": "Weitere Einträge konnten nicht geladen werden."
|
||||||
"more": "Weitere Einträge konnten nicht geladen werden."
|
},
|
||||||
},
|
"sections": {
|
||||||
"sections": {
|
"overview": {
|
||||||
"overview": {
|
"title": "Paketübersicht",
|
||||||
"title": "Paketübersicht",
|
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
|
||||||
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
|
"empty": "Noch kein Paket aktiv.",
|
||||||
"empty": "Noch kein Paket aktiv.",
|
"emptyBadge": "Kein aktives Paket",
|
||||||
"emptyBadge": "Kein aktives Paket",
|
"cards": {
|
||||||
"cards": {
|
"package": {
|
||||||
"package": {
|
"label": "Aktives Paket",
|
||||||
"label": "Aktives Paket",
|
"helper": "Aktuell zugewiesen"
|
||||||
"helper": "Aktuell zugewiesen"
|
},
|
||||||
},
|
"used": {
|
||||||
"used": {
|
"label": "Genutzte Events",
|
||||||
"label": "Genutzte Events",
|
"helper": "Verfügbar: {{count}}"
|
||||||
"helper": "Verfügbar: {{count}}"
|
},
|
||||||
},
|
"price": {
|
||||||
"price": {
|
"label": "Preis (netto)"
|
||||||
"label": "Preis (netto)"
|
},
|
||||||
},
|
"expires": {
|
||||||
"expires": {
|
"label": "Läuft ab",
|
||||||
"label": "Läuft ab",
|
"helper": "Automatische Verlängerung, falls aktiv"
|
||||||
"helper": "Automatische Verlängerung, falls aktiv"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"packages": {
|
},
|
||||||
"title": "Paket-Historie",
|
"packages": {
|
||||||
"description": "Übersicht über aktuelle und vergangene Pakete.",
|
"title": "Paket-Historie",
|
||||||
"empty": "Noch keine Pakete gebucht.",
|
"description": "Übersicht über aktuelle und vergangene Pakete.",
|
||||||
"card": {
|
"empty": "Noch keine Pakete gebucht.",
|
||||||
"statusActive": "Aktiv",
|
"card": {
|
||||||
"statusInactive": "Inaktiv",
|
"statusActive": "Aktiv",
|
||||||
"used": "Genutzte Events",
|
"statusInactive": "Inaktiv",
|
||||||
"available": "Verfügbar",
|
"used": "Genutzte Events",
|
||||||
"expires": "Läuft ab"
|
"available": "Verfügbar",
|
||||||
}
|
"expires": "Läuft ab"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,46 +165,25 @@
|
|||||||
"failureTitle": "Aktivierung fehlgeschlagen",
|
"failureTitle": "Aktivierung fehlgeschlagen",
|
||||||
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
|
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
|
||||||
},
|
},
|
||||||
"stripe": {
|
"paddle": {
|
||||||
"sectionTitle": "Kartenzahlung (Stripe)",
|
"sectionTitle": "Paddle",
|
||||||
"heading": "Kartenzahlung",
|
"heading": "Checkout mit Paddle",
|
||||||
"notReady": "Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.",
|
"genericError": "Der Paddle-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.",
|
||||||
"genericError": "Zahlung fehlgeschlagen. Bitte erneut versuchen.",
|
"errorTitle": "Paddle-Fehler",
|
||||||
"missingPaymentId": "Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).",
|
"processing": "Paddle-Checkout wird geöffnet …",
|
||||||
"completionFailed": "Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.",
|
"cta": "Paddle-Checkout öffnen",
|
||||||
"errorTitle": "Zahlung fehlgeschlagen",
|
"hint": "Es öffnet sich ein neuer Tab über Paddle (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück."
|
||||||
"submitting": "Zahlung wird bestätigt …",
|
|
||||||
"submit": "Jetzt bezahlen",
|
|
||||||
"hint": "Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.",
|
|
||||||
"loading": "Zahlungsdetails werden geladen …",
|
|
||||||
"unavailableTitle": "Stripe nicht verfügbar",
|
|
||||||
"unavailableDescription": "Stripe konnte nicht initialisiert werden.",
|
|
||||||
"missingKey": "Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.",
|
|
||||||
"intentFailed": "Stripe-Zahlung konnte nicht vorbereitet werden."
|
|
||||||
},
|
|
||||||
"paypal": {
|
|
||||||
"sectionTitle": "PayPal",
|
|
||||||
"heading": "PayPal",
|
|
||||||
"createFailed": "PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.",
|
|
||||||
"captureFailed": "PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.",
|
|
||||||
"errorTitle": "PayPal-Fehler",
|
|
||||||
"genericError": "PayPal hat ein Problem gemeldet. Bitte versuche es später erneut.",
|
|
||||||
"missingOrderId": "PayPal hat keine Order-ID geliefert.",
|
|
||||||
"cancelled": "PayPal-Zahlung wurde abgebrochen.",
|
|
||||||
"hint": "PayPal leitet dich ggf. weiter, um die Zahlung zu bestätigen. Anschließend kommst du automatisch zurück.",
|
|
||||||
"notConfiguredTitle": "PayPal nicht konfiguriert",
|
|
||||||
"notConfiguredDescription": "Hinterlege VITE_PAYPAL_CLIENT_ID, damit Gastgeber optional mit PayPal bezahlen können."
|
|
||||||
},
|
},
|
||||||
"nextStepsTitle": "Nächste Schritte",
|
"nextStepsTitle": "Nächste Schritte",
|
||||||
"nextSteps": [
|
"nextSteps": [
|
||||||
"Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.",
|
"Optional: Abrechnung über Paddle im Billing-Bereich abschließen.",
|
||||||
"Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.",
|
"Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.",
|
||||||
"Vor dem Go-Live Credits prüfen und Gäste-Link teilen."
|
"Vor dem Go-Live Credits prüfen und Gäste-Link teilen."
|
||||||
],
|
],
|
||||||
"cta": {
|
"cta": {
|
||||||
"billing": {
|
"billing": {
|
||||||
"label": "Abrechnung starten",
|
"label": "Abrechnung starten",
|
||||||
"description": "Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).",
|
"description": "Öffnet den Billing-Bereich mit Paddle- und Credit-Optionen.",
|
||||||
"button": "Zu Billing & Zahlung"
|
"button": "Zu Billing & Zahlung"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
@@ -262,7 +241,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,27 @@
|
|||||||
"available": "Remaining",
|
"available": "Remaining",
|
||||||
"expires": "Expires"
|
"expires": "Expires"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"title": "Paddle transactions",
|
||||||
|
"description": "Recent Paddle transactions for this tenant.",
|
||||||
|
"empty": "No Paddle transactions yet.",
|
||||||
|
"labels": {
|
||||||
|
"transactionId": "Transaction {{id}}",
|
||||||
|
"checkoutId": "Checkout ID: {{id}}",
|
||||||
|
"origin": "Origin: {{origin}}",
|
||||||
|
"receipt": "View receipt",
|
||||||
|
"tax": "Tax: {{value}}"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"completed": "Completed",
|
||||||
|
"processing": "Processing",
|
||||||
|
"failed": "Failed",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"loadMore": "Load more transactions",
|
||||||
|
"loadingMore": "Loading…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -149,8 +170,7 @@
|
|||||||
"high": "High",
|
"high": "High",
|
||||||
"urgent": "Urgent"
|
"urgent": "Urgent"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
,
|
|
||||||
"collections": {
|
"collections": {
|
||||||
"title": "Task collections",
|
"title": "Task collections",
|
||||||
"subtitle": "Browse curated task bundles or activate them for your events.",
|
"subtitle": "Browse curated task bundles or activate them for your events.",
|
||||||
@@ -244,57 +264,56 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"submit": "Save emotion"
|
"submit": "Save emotion"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
,
|
"management": {
|
||||||
"management": {
|
"billing": {
|
||||||
"billing": {
|
"title": "Packages & billing",
|
||||||
"title": "Packages & billing",
|
"subtitle": "Manage your purchased packages and track their durations.",
|
||||||
"subtitle": "Manage your purchased packages and track their durations.",
|
"actions": {
|
||||||
"actions": {
|
"refresh": "Refresh",
|
||||||
"refresh": "Refresh",
|
"exportCsv": "Export CSV"
|
||||||
"exportCsv": "Export CSV"
|
},
|
||||||
},
|
"errors": {
|
||||||
"errors": {
|
"load": "Unable to load package data.",
|
||||||
"load": "Unable to load package data.",
|
"more": "Unable to load more entries."
|
||||||
"more": "Unable to load more entries."
|
},
|
||||||
},
|
"sections": {
|
||||||
"sections": {
|
"overview": {
|
||||||
"overview": {
|
"title": "Package overview",
|
||||||
"title": "Package overview",
|
"description": "Your active package and the most important metrics.",
|
||||||
"description": "Your active package and the most important metrics.",
|
"empty": "No active package yet.",
|
||||||
"empty": "No active package yet.",
|
"emptyBadge": "No active package",
|
||||||
"emptyBadge": "No active package",
|
"cards": {
|
||||||
"cards": {
|
"package": {
|
||||||
"package": {
|
"label": "Active package",
|
||||||
"label": "Active package",
|
"helper": "Currently assigned"
|
||||||
"helper": "Currently assigned"
|
},
|
||||||
},
|
"used": {
|
||||||
"used": {
|
"label": "Events used",
|
||||||
"label": "Events used",
|
"helper": "Remaining: {{count}}"
|
||||||
"helper": "Remaining: {{count}}"
|
},
|
||||||
},
|
"price": {
|
||||||
"price": {
|
"label": "Price (net)"
|
||||||
"label": "Price (net)"
|
},
|
||||||
},
|
"expires": {
|
||||||
"expires": {
|
"label": "Expires",
|
||||||
"label": "Expires",
|
"helper": "Auto-renews if enabled"
|
||||||
"helper": "Auto-renews if enabled"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"packages": {
|
},
|
||||||
"title": "Package history",
|
"packages": {
|
||||||
"description": "Overview of current and past packages.",
|
"title": "Package history",
|
||||||
"empty": "No packages purchased yet.",
|
"description": "Overview of current and past packages.",
|
||||||
"card": {
|
"empty": "No packages purchased yet.",
|
||||||
"statusActive": "Active",
|
"card": {
|
||||||
"statusInactive": "Inactive",
|
"statusActive": "Active",
|
||||||
"used": "Used events",
|
"statusInactive": "Inactive",
|
||||||
"available": "Available",
|
"used": "Used events",
|
||||||
"expires": "Expires"
|
"available": "Available",
|
||||||
}
|
"expires": "Expires"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -165,46 +165,25 @@
|
|||||||
"failureTitle": "Activation failed",
|
"failureTitle": "Activation failed",
|
||||||
"errorMessage": "The free package could not be activated."
|
"errorMessage": "The free package could not be activated."
|
||||||
},
|
},
|
||||||
"stripe": {
|
"paddle": {
|
||||||
"sectionTitle": "Card payment (Stripe)",
|
"sectionTitle": "Paddle",
|
||||||
"heading": "Card payment",
|
"heading": "Checkout with Paddle",
|
||||||
"notReady": "Payment module not ready yet. Please refresh.",
|
"genericError": "The Paddle checkout could not be opened. Please try again.",
|
||||||
"genericError": "Payment failed. Please try again.",
|
"errorTitle": "Paddle error",
|
||||||
"missingPaymentId": "Could not confirm payment (missing payment ID).",
|
"processing": "Opening the Paddle checkout …",
|
||||||
"completionFailed": "Purchase not recorded yet. Contact support with your payment confirmation.",
|
"cta": "Open Paddle checkout",
|
||||||
"errorTitle": "Payment failed",
|
"hint": "A new tab opens via Paddle (merchant of record). Complete the payment there, then return to continue."
|
||||||
"submitting": "Confirming payment …",
|
|
||||||
"submit": "Pay now",
|
|
||||||
"hint": "Secure checkout via Stripe. You'll receive confirmation once recorded.",
|
|
||||||
"loading": "Loading payment details …",
|
|
||||||
"unavailableTitle": "Stripe unavailable",
|
|
||||||
"unavailableDescription": "Stripe could not be initialised.",
|
|
||||||
"missingKey": "Stripe publishable key missing. Configure VITE_STRIPE_PUBLISHABLE_KEY.",
|
|
||||||
"intentFailed": "Stripe could not prepare the payment."
|
|
||||||
},
|
|
||||||
"paypal": {
|
|
||||||
"sectionTitle": "PayPal",
|
|
||||||
"heading": "PayPal",
|
|
||||||
"createFailed": "PayPal order could not be created. Please try again.",
|
|
||||||
"captureFailed": "PayPal payment could not be captured. Contact support if funds were withdrawn.",
|
|
||||||
"errorTitle": "PayPal error",
|
|
||||||
"genericError": "PayPal reported a problem. Please try again later.",
|
|
||||||
"missingOrderId": "PayPal did not return an order ID.",
|
|
||||||
"cancelled": "PayPal payment was cancelled.",
|
|
||||||
"hint": "PayPal may redirect you briefly to confirm. You'll return automatically afterwards.",
|
|
||||||
"notConfiguredTitle": "PayPal not configured",
|
|
||||||
"notConfiguredDescription": "Provide VITE_PAYPAL_CLIENT_ID so hosts can pay with PayPal."
|
|
||||||
},
|
},
|
||||||
"nextStepsTitle": "Next steps",
|
"nextStepsTitle": "Next steps",
|
||||||
"nextSteps": [
|
"nextSteps": [
|
||||||
"Optional: finish billing (Stripe/PayPal) inside the billing area.",
|
"Optional: finish billing via Paddle inside the billing area.",
|
||||||
"Complete the event setup and configure tasks, team, and gallery.",
|
"Complete the event setup and configure tasks, team, and gallery.",
|
||||||
"Check credits before go-live and share your guest link."
|
"Check credits before go-live and share your guest link."
|
||||||
],
|
],
|
||||||
"cta": {
|
"cta": {
|
||||||
"billing": {
|
"billing": {
|
||||||
"label": "Start billing",
|
"label": "Start billing",
|
||||||
"description": "Opens the billing area with Stripe, PayPal, and credit options.",
|
"description": "Opens the billing area with Paddle and credit options.",
|
||||||
"button": "Go to billing"
|
"button": "Go to billing"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
|
|||||||
@@ -1,176 +1,65 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
import {
|
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
|
||||||
StripeCheckoutForm,
|
|
||||||
PayPalCheckout,
|
|
||||||
} from '../pages/WelcomeOrderSummaryPage';
|
|
||||||
|
|
||||||
const stripeRef: { current: any } = { current: null };
|
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
|
||||||
const elementsRef: { current: any } = { current: null };
|
createPaddleCheckoutMock: vi.fn(),
|
||||||
const paypalPropsRef: { current: any } = { current: null };
|
|
||||||
|
|
||||||
const {
|
|
||||||
confirmPaymentMock,
|
|
||||||
completePurchaseMock,
|
|
||||||
createPayPalOrderMock,
|
|
||||||
capturePayPalOrderMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
confirmPaymentMock: vi.fn(),
|
|
||||||
completePurchaseMock: vi.fn(),
|
|
||||||
createPayPalOrderMock: vi.fn(),
|
|
||||||
capturePayPalOrderMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@stripe/react-stripe-js', () => ({
|
|
||||||
useStripe: () => stripeRef.current,
|
|
||||||
useElements: () => elementsRef.current,
|
|
||||||
PaymentElement: () => <div data-testid="stripe-payment-element" />,
|
|
||||||
Elements: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@paypal/react-paypal-js', () => ({
|
|
||||||
PayPalScriptProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
PayPalButtons: (props: any) => {
|
|
||||||
paypalPropsRef.current = props;
|
|
||||||
return <button type="button" data-testid="paypal-button">PayPal</button>;
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../api', () => ({
|
vi.mock('../../api', () => ({
|
||||||
completeTenantPackagePurchase: completePurchaseMock,
|
|
||||||
createTenantPackagePaymentIntent: vi.fn(),
|
|
||||||
assignFreeTenantPackage: vi.fn(),
|
assignFreeTenantPackage: vi.fn(),
|
||||||
createTenantPayPalOrder: createPayPalOrderMock,
|
createTenantPaddleCheckout: createPaddleCheckoutMock,
|
||||||
captureTenantPayPalOrder: capturePayPalOrderMock,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('StripeCheckoutForm', () => {
|
describe('PaddleCheckout', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
confirmPaymentMock.mockReset();
|
createPaddleCheckoutMock.mockReset();
|
||||||
completePurchaseMock.mockReset();
|
|
||||||
stripeRef.current = { confirmPayment: confirmPaymentMock };
|
|
||||||
elementsRef.current = {};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderStripeForm = (overrides?: Partial<React.ComponentProps<typeof StripeCheckoutForm>>) =>
|
it('opens Paddle checkout when created successfully', async () => {
|
||||||
render(
|
createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
|
||||||
<StripeCheckoutForm
|
|
||||||
clientSecret="secret"
|
|
||||||
packageId={42}
|
|
||||||
onSuccess={vi.fn()}
|
|
||||||
t={(key: string) => key}
|
|
||||||
{...overrides}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
it('completes the purchase when Stripe reports a successful payment', async () => {
|
|
||||||
const onSuccess = vi.fn();
|
|
||||||
confirmPaymentMock.mockResolvedValue({
|
|
||||||
error: null,
|
|
||||||
paymentIntent: { payment_method: 'pm_123' },
|
|
||||||
});
|
|
||||||
completePurchaseMock.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
const { container } = renderStripeForm({ onSuccess });
|
|
||||||
const form = container.querySelector('form');
|
|
||||||
expect(form).toBeTruthy();
|
|
||||||
fireEvent.submit(form!);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(completePurchaseMock).toHaveBeenCalledWith({
|
|
||||||
packageId: 42,
|
|
||||||
paymentMethodId: 'pm_123',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(onSuccess).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows Stripe errors returned by confirmPayment', async () => {
|
|
||||||
confirmPaymentMock.mockResolvedValue({
|
|
||||||
error: { message: 'Card declined' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = renderStripeForm();
|
|
||||||
fireEvent.submit(container.querySelector('form')!);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Card declined')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(completePurchaseMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports missing payment method id', async () => {
|
|
||||||
confirmPaymentMock.mockResolvedValue({
|
|
||||||
error: null,
|
|
||||||
paymentIntent: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = renderStripeForm();
|
|
||||||
fireEvent.submit(container.querySelector('form')!);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('summary.stripe.missingPaymentId')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(completePurchaseMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PayPalCheckout', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
paypalPropsRef.current = null;
|
|
||||||
createPayPalOrderMock.mockReset();
|
|
||||||
capturePayPalOrderMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates and captures a PayPal order successfully', async () => {
|
|
||||||
createPayPalOrderMock.mockResolvedValue('ORDER-123');
|
|
||||||
capturePayPalOrderMock.mockResolvedValue(undefined);
|
|
||||||
const onSuccess = vi.fn();
|
const onSuccess = vi.fn();
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<PayPalCheckout
|
<PaddleCheckout
|
||||||
packageId={99}
|
packageId={99}
|
||||||
onSuccess={onSuccess}
|
onSuccess={onSuccess}
|
||||||
t={(key: string) => key}
|
t={(key: string) => key}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(paypalPropsRef.current).toBeTruthy();
|
|
||||||
const { createOrder, onApprove } = paypalPropsRef.current;
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const orderId = await createOrder();
|
screen.getByRole('button').click();
|
||||||
expect(orderId).toBe('ORDER-123');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
await onApprove({ orderID: 'ORDER-123' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createPayPalOrderMock).toHaveBeenCalledWith(99);
|
expect(createPaddleCheckoutMock).toHaveBeenCalledWith(99);
|
||||||
expect(capturePayPalOrderMock).toHaveBeenCalledWith('ORDER-123');
|
expect(openSpy).toHaveBeenCalledWith('https://paddle.example/checkout', '_blank', 'noopener');
|
||||||
expect(onSuccess).toHaveBeenCalled();
|
expect(onSuccess).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
openSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces missing order id errors', async () => {
|
it('shows an error message on failure', async () => {
|
||||||
createPayPalOrderMock.mockResolvedValue('ORDER-123');
|
createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<PayPalCheckout
|
<PaddleCheckout
|
||||||
packageId={99}
|
packageId={99}
|
||||||
onSuccess={vi.fn()}
|
onSuccess={vi.fn()}
|
||||||
t={(key: string) => key}
|
t={(key: string) => key}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const { onApprove } = paypalPropsRef.current;
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await onApprove({ orderID: undefined });
|
screen.getByRole('button').click();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('summary.paypal.missingOrderId')).toBeInTheDocument();
|
expect(screen.getByText('summary.paddle.genericError')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(capturePayPalOrderMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
|
||||||
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TenantWelcomeLayout,
|
TenantWelcomeLayout,
|
||||||
@@ -26,30 +24,15 @@ import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PA
|
|||||||
import { useTenantPackages } from "../hooks/useTenantPackages";
|
import { useTenantPackages } from "../hooks/useTenantPackages";
|
||||||
import {
|
import {
|
||||||
assignFreeTenantPackage,
|
assignFreeTenantPackage,
|
||||||
completeTenantPackagePurchase,
|
createTenantPaddleCheckout,
|
||||||
createTenantPackagePaymentIntent,
|
|
||||||
createTenantPayPalOrder,
|
|
||||||
captureTenantPayPalOrder,
|
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
import { getStripe } from '@/utils/stripe';
|
|
||||||
|
|
||||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
type PaddleCheckoutProps = {
|
||||||
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
|
|
||||||
|
|
||||||
type StripeCheckoutProps = {
|
|
||||||
clientSecret: string;
|
|
||||||
packageId: number;
|
packageId: number;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
t: ReturnType<typeof useTranslation>["t"];
|
t: ReturnType<typeof useTranslation>["t"];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PayPalCheckoutProps = {
|
|
||||||
packageId: number;
|
|
||||||
onSuccess: () => void;
|
|
||||||
t: ReturnType<typeof useTranslation>["t"];
|
|
||||||
currency?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function useLocaleFormats(locale: string) {
|
function useLocaleFormats(locale: string) {
|
||||||
const currencyFormatter = React.useMemo(
|
const currencyFormatter = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -86,175 +69,53 @@ function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string)
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) {
|
function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
|
||||||
const stripe = useStripe();
|
const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||||
const elements = useElements();
|
|
||||||
const [submitting, setSubmitting] = React.useState(false);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleCheckout = React.useCallback(async () => {
|
||||||
event.preventDefault();
|
|
||||||
if (!stripe || !elements) {
|
|
||||||
setError(t("summary.stripe.notReady"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const result = await stripe.confirmPayment({
|
|
||||||
elements,
|
|
||||||
confirmParams: {
|
|
||||||
return_url: window.location.href,
|
|
||||||
},
|
|
||||||
redirect: "if_required",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? t("summary.stripe.genericError"));
|
|
||||||
setSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentIntent = result.paymentIntent;
|
|
||||||
const paymentMethodId =
|
|
||||||
typeof paymentIntent?.payment_method === "string"
|
|
||||||
? paymentIntent.payment_method
|
|
||||||
: typeof paymentIntent?.id === "string"
|
|
||||||
? paymentIntent.id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!paymentMethodId) {
|
|
||||||
setError(t("summary.stripe.missingPaymentId"));
|
|
||||||
setSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeTenantPackagePurchase({
|
setStatus('processing');
|
||||||
packageId,
|
setError(null);
|
||||||
paymentMethodId,
|
const { checkout_url } = await createTenantPaddleCheckout(packageId);
|
||||||
});
|
window.open(checkout_url, '_blank', 'noopener');
|
||||||
|
setStatus('success');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (purchaseError) {
|
} catch (err) {
|
||||||
console.error("[Onboarding] Purchase completion failed", purchaseError);
|
console.error('[Onboarding] Paddle checkout failed', err);
|
||||||
setError(
|
setStatus('error');
|
||||||
purchaseError instanceof Error
|
setError(err instanceof Error ? err.message : t('summary.paddle.genericError'));
|
||||||
? purchaseError.message
|
|
||||||
: t("summary.stripe.completionFailed")
|
|
||||||
);
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, [packageId, onSuccess, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary">
|
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
|
||||||
<div className="space-y-3">
|
<p className="text-sm font-medium text-brand-slate">{t('summary.paddle.heading')}</p>
|
||||||
<p className="text-sm font-medium text-brand-slate">{t("summary.stripe.heading")}</p>
|
|
||||||
<PaymentElement id="payment-element" />
|
|
||||||
</div>
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle>
|
<AlertTitle>{t('summary.paddle.errorTitle')}</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
size="lg"
|
||||||
disabled={submitting || !stripe || !elements}
|
|
||||||
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
|
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
|
||||||
|
disabled={status === 'processing'}
|
||||||
|
onClick={handleCheckout}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{status === 'processing' ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
{t("summary.stripe.submitting")}
|
{t('summary.paddle.processing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CreditCard className="mr-2 size-4" />
|
<CreditCard className="mr-2 size-4" />
|
||||||
{t("summary.stripe.submit")}
|
{t('summary.paddle.cta')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-brand-navy/70">{t("summary.stripe.hint")}</p>
|
<p className="text-xs text-brand-navy/70">{t('summary.paddle.hint')}</p>
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PayPalCheckout({ packageId, onSuccess, t, currency = "EUR" }: PayPalCheckoutProps) {
|
|
||||||
const [status, setStatus] = React.useState<"idle" | "creating" | "capturing" | "error" | "success">("idle");
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleCreateOrder = React.useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setStatus("creating");
|
|
||||||
const orderId = await createTenantPayPalOrder(packageId);
|
|
||||||
setStatus("idle");
|
|
||||||
setError(null);
|
|
||||||
return orderId;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[Onboarding] PayPal create order failed", err);
|
|
||||||
setStatus("error");
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : t("summary.paypal.createFailed")
|
|
||||||
);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [packageId, t]);
|
|
||||||
|
|
||||||
const handleApprove = React.useCallback(
|
|
||||||
async (orderId: string) => {
|
|
||||||
try {
|
|
||||||
setStatus("capturing");
|
|
||||||
await captureTenantPayPalOrder(orderId);
|
|
||||||
setStatus("success");
|
|
||||||
setError(null);
|
|
||||||
onSuccess();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[Onboarding] PayPal capture failed", err);
|
|
||||||
setStatus("error");
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : t("summary.paypal.captureFailed")
|
|
||||||
);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onSuccess, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
|
|
||||||
<p className="text-sm font-medium text-brand-slate">{t("summary.paypal.heading")}</p>
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>{t("summary.paypal.errorTitle")}</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<PayPalButtons
|
|
||||||
style={{ layout: "vertical" }}
|
|
||||||
forceReRender={[packageId, currency]}
|
|
||||||
createOrder={async () => handleCreateOrder()}
|
|
||||||
onApprove={async (data) => {
|
|
||||||
if (!data.orderID) {
|
|
||||||
setError(t("summary.paypal.missingOrderId"));
|
|
||||||
setStatus("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await handleApprove(data.orderID);
|
|
||||||
}}
|
|
||||||
onError={(err) => {
|
|
||||||
console.error("[Onboarding] PayPal onError", err);
|
|
||||||
setStatus("error");
|
|
||||||
setError(t("summary.paypal.genericError"));
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setStatus("idle");
|
|
||||||
setError(t("summary.paypal.cancelled"));
|
|
||||||
}}
|
|
||||||
disabled={status === "creating" || status === "capturing"}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-brand-navy/70">{t("summary.paypal.hint")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -267,7 +128,6 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
const { t, i18n } = useTranslation("onboarding");
|
const { t, i18n } = useTranslation("onboarding");
|
||||||
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
|
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
|
||||||
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
|
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
|
||||||
const stripePromise = React.useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
|
|
||||||
|
|
||||||
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
|
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
|
||||||
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
||||||
@@ -295,48 +155,9 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
const isSubscription = Boolean(packageDetails?.features?.subscription);
|
const isSubscription = Boolean(packageDetails?.features?.subscription);
|
||||||
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
|
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
|
||||||
|
|
||||||
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
|
|
||||||
const [intentStatus, setIntentStatus] = React.useState<"idle" | "loading" | "error" | "ready">("idle");
|
|
||||||
const [intentError, setIntentError] = React.useState<string | null>(null);
|
|
||||||
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
|
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
|
||||||
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
|
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!requiresPayment || !packageDetails) {
|
|
||||||
setClientSecret(null);
|
|
||||||
setIntentStatus("idle");
|
|
||||||
setIntentError(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stripePromise) {
|
|
||||||
setIntentError(t("summary.stripe.missingKey"));
|
|
||||||
setIntentStatus("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
setIntentStatus("loading");
|
|
||||||
setIntentError(null);
|
|
||||||
|
|
||||||
createTenantPackagePaymentIntent(packageDetails.id)
|
|
||||||
.then((secret) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setClientSecret(secret);
|
|
||||||
setIntentStatus("ready");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Onboarding] Payment intent failed", error);
|
|
||||||
if (cancelled) return;
|
|
||||||
setIntentStatus("error");
|
|
||||||
setIntentError(error instanceof Error ? error.message : t("summary.stripe.intentFailed"));
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [requiresPayment, packageDetails, stripePromise, t]);
|
|
||||||
|
|
||||||
const priceText =
|
const priceText =
|
||||||
progress.selectedPackage?.priceText ??
|
progress.selectedPackage?.priceText ??
|
||||||
(packageDetails && typeof packageDetails.price === "number"
|
(packageDetails && typeof packageDetails.price === "number"
|
||||||
@@ -534,63 +355,16 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{requiresPayment && (
|
{requiresPayment && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div className="space-y-4">
|
<h4 className="text-lg font-semibold text-brand-slate">{t('summary.paddle.sectionTitle')}</h4>
|
||||||
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.stripe.sectionTitle")}</h4>
|
<PaddleCheckout
|
||||||
{intentStatus === "loading" && (
|
packageId={packageDetails.id}
|
||||||
<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">
|
onSuccess={() => {
|
||||||
<Loader2 className="size-4 animate-spin text-brand-rose" />
|
markStep({ packageSelected: true });
|
||||||
{t("summary.stripe.loading")}
|
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||||
</div>
|
}}
|
||||||
)}
|
t={t}
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -634,4 +408,4 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { StripeCheckoutForm, PayPalCheckout };
|
export { PaddleCheckout };
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import { getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
|
|
||||||
export default function BillingPage() {
|
export default function BillingPage() {
|
||||||
@@ -21,6 +21,10 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||||
|
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
|
||||||
|
const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null);
|
||||||
|
const [transactionsHasMore, setTransactionsHasMore] = React.useState(false);
|
||||||
|
const [transactionsLoading, setTransactionsLoading] = React.useState(false);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
@@ -57,9 +61,18 @@ export default function BillingPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const packagesResult = await getTenantPackagesOverview();
|
const [packagesResult, paddleTransactions] = await Promise.all([
|
||||||
|
getTenantPackagesOverview(),
|
||||||
|
getTenantPaddleTransactions().catch((err) => {
|
||||||
|
console.warn('Failed to load Paddle transactions', err);
|
||||||
|
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
|
||||||
|
}),
|
||||||
|
]);
|
||||||
setPackages(packagesResult.packages);
|
setPackages(packagesResult.packages);
|
||||||
setActivePackage(packagesResult.activePackage);
|
setActivePackage(packagesResult.activePackage);
|
||||||
|
setTransactions(paddleTransactions.data);
|
||||||
|
setTransactionCursor(paddleTransactions.nextCursor);
|
||||||
|
setTransactionsHasMore(paddleTransactions.hasMore);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError(t('billing.errors.load'));
|
setError(t('billing.errors.load'));
|
||||||
@@ -69,6 +82,25 @@ export default function BillingPage() {
|
|||||||
}
|
}
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
const loadMoreTransactions = React.useCallback(async () => {
|
||||||
|
if (!transactionsHasMore || transactionsLoading || !transactionCursor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransactionsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getTenantPaddleTransactions(transactionCursor);
|
||||||
|
setTransactions((current) => [...current, ...result.data]);
|
||||||
|
setTransactionCursor(result.nextCursor);
|
||||||
|
setTransactionsHasMore(result.hasMore && Boolean(result.nextCursor));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load additional Paddle transactions', error);
|
||||||
|
setTransactionsHasMore(false);
|
||||||
|
} finally {
|
||||||
|
setTransactionsLoading(false);
|
||||||
|
}
|
||||||
|
}, [transactionCursor, transactionsHasMore, transactionsLoading]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void loadAll();
|
void loadAll();
|
||||||
}, [loadAll]);
|
}, [loadAll]);
|
||||||
@@ -176,11 +208,134 @@ export default function BillingPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
|
<Sparkles className="h-5 w-5 text-sky-500" />
|
||||||
|
{t('billing.sections.transactions.title')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-slate-600">
|
||||||
|
{t('billing.sections.transactions.description')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<EmptyState message={t('billing.sections.transactions.empty')} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{transactions.map((transaction) => (
|
||||||
|
<TransactionCard
|
||||||
|
key={transaction.id ?? Math.random().toString(36).slice(2)}
|
||||||
|
transaction={transaction}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
formatDate={formatDate}
|
||||||
|
locale={locale}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{transactionsHasMore && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void loadMoreTransactions()}
|
||||||
|
disabled={transactionsLoading}
|
||||||
|
>
|
||||||
|
{transactionsLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t('billing.sections.transactions.loadingMore')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('billing.sections.transactions.loadMore')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TransactionCard({
|
||||||
|
transaction,
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
locale,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
transaction: PaddleTransactionSummary;
|
||||||
|
formatCurrency: (value: number | null | undefined, currency?: string) => string;
|
||||||
|
formatDate: (value: string | null | undefined) => string;
|
||||||
|
locale: string;
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}) {
|
||||||
|
const amount = transaction.grand_total ?? transaction.amount ?? null;
|
||||||
|
const currency = transaction.currency ?? 'EUR';
|
||||||
|
const createdAtIso = transaction.created_at ?? null;
|
||||||
|
const createdAt = createdAtIso ? new Date(createdAtIso) : null;
|
||||||
|
const createdLabel = createdAt
|
||||||
|
? createdAt.toLocaleString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: formatDate(createdAtIso);
|
||||||
|
const statusKey = transaction.status ? `billing.sections.transactions.status.${transaction.status}` : 'billing.sections.transactions.status.unknown';
|
||||||
|
const statusText = t(statusKey, {
|
||||||
|
defaultValue: (transaction.status ?? 'unknown').replace(/_/g, ' '),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-slate-800">
|
||||||
|
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-500">{createdLabel}</p>
|
||||||
|
{transaction.checkout_id && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{transaction.origin && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t('billing.sections.transactions.labels.origin', { origin: transaction.origin })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start gap-2 text-sm font-medium text-slate-700 md:flex-row md:items-center md:gap-4">
|
||||||
|
<Badge className="bg-sky-100 text-sky-700">
|
||||||
|
{statusText}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-base font-semibold text-slate-900">
|
||||||
|
{formatCurrency(amount, currency)}
|
||||||
|
</div>
|
||||||
|
{transaction.tax !== undefined && transaction.tax !== null && (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{transaction.receipt_url && (
|
||||||
|
<a
|
||||||
|
href={transaction.receipt_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs font-medium text-sky-600 hover:text-sky-700"
|
||||||
|
>
|
||||||
|
{t('billing.sections.transactions.labels.receipt')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InfoCard({
|
function InfoCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ const Footer: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<img src="logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
<img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
||||||
<div>
|
<div>
|
||||||
<Link href="/" className="text-2xl font-bold font-display text-pink-500">
|
<Link href="/" className="text-2xl font-bold font-display text-pink-500">
|
||||||
FotoSpiel.App
|
Die FotoSpiel.App
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-gray-600 font-sans-marketing mt-2">
|
<p className="text-gray-600 font-sans-marketing mt-2">
|
||||||
Deine Plattform für Event-Fotos.
|
Deine Plattform für Event-Fotos.
|
||||||
@@ -57,7 +57,7 @@ const Footer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 mt-8 pt-8 text-center text-sm text-gray-500 font-sans-marketing">
|
<div className="border-t border-gray-200 mt-8 pt-8 text-center text-sm text-gray-500 font-sans-marketing">
|
||||||
© 2025 FotoSpiel.App - Alle Rechte vorbehalten.
|
© 2025 Die FotoSpiel.App - Alle Rechte vorbehalten.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const Header: React.FC = () => {
|
|||||||
<Link href={localizedPath('/')} className="flex items-center gap-4">
|
<Link href={localizedPath('/')} className="flex items-center gap-4">
|
||||||
<img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
<img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
||||||
<span className="text-2xl font-bold font-display text-pink-500">
|
<span className="text-2xl font-bold font-display text-pink-500">
|
||||||
FotoSpiel.App
|
Die FotoSpiel.App
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
|
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react';
|
import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import type { GoogleProfilePrefill } from '../marketing/checkout/types';
|
||||||
|
|
||||||
declare const route: (name: string, params?: Record<string, unknown>) => string;
|
declare const route: (name: string, params?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ interface RegisterFormProps {
|
|||||||
onSuccess?: (payload: RegisterSuccessPayload) => void;
|
onSuccess?: (payload: RegisterSuccessPayload) => void;
|
||||||
privacyHtml: string;
|
privacyHtml: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
prefill?: GoogleProfilePrefill;
|
||||||
|
onClearGoogleProfile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegisterFormFields = {
|
type RegisterFormFields = {
|
||||||
@@ -30,13 +33,15 @@ type RegisterFormFields = {
|
|||||||
address: string;
|
address: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
privacy_consent: boolean;
|
privacy_consent: boolean;
|
||||||
|
terms: boolean;
|
||||||
package_id: number | null;
|
package_id: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }: RegisterFormProps) {
|
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale, prefill, onClearGoogleProfile }: RegisterFormProps) {
|
||||||
const [privacyOpen, setPrivacyOpen] = useState(false);
|
const [privacyOpen, setPrivacyOpen] = useState(false);
|
||||||
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [prefillApplied, setPrefillApplied] = useState(false);
|
||||||
const { t } = useTranslation(['auth', 'common']);
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
|
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
|
||||||
const resolvedLocale = locale ?? page.props.locale ?? 'de';
|
const resolvedLocale = locale ?? page.props.locale ?? 'de';
|
||||||
@@ -51,6 +56,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
address: '',
|
address: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
privacy_consent: false,
|
privacy_consent: false,
|
||||||
|
terms: false,
|
||||||
package_id: packageId || null,
|
package_id: packageId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,6 +68,62 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
|
|
||||||
const registerEndpoint = '/checkout/register';
|
const registerEndpoint = '/checkout/register';
|
||||||
|
|
||||||
|
const namePrefill = useMemo(() => {
|
||||||
|
const rawFirst = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? '';
|
||||||
|
const remaining = prefill?.name ? prefill.name.split(' ').slice(1).join(' ') : '';
|
||||||
|
const rawLast = prefill?.family_name ?? remaining;
|
||||||
|
|
||||||
|
return {
|
||||||
|
first: rawFirst ?? '',
|
||||||
|
last: rawLast ?? '',
|
||||||
|
};
|
||||||
|
}, [prefill]);
|
||||||
|
|
||||||
|
const suggestedUsername = useMemo(() => {
|
||||||
|
if (prefill?.email) {
|
||||||
|
const localPart = prefill.email.split('@')[0];
|
||||||
|
if (localPart) {
|
||||||
|
return localPart.slice(0, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = prefill?.given_name ?? prefill?.name?.split(' ')[0] ?? '';
|
||||||
|
const last = prefill?.family_name ?? prefill?.name?.split(' ').slice(1).join(' ') ?? '';
|
||||||
|
const combined = `${first}${last}`.trim();
|
||||||
|
if (!combined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '')
|
||||||
|
.slice(0, 30) || undefined;
|
||||||
|
}, [prefill]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prefill || prefillApplied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (namePrefill.first && !data.first_name) {
|
||||||
|
setData('first_name', namePrefill.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (namePrefill.last && !data.last_name) {
|
||||||
|
setData('last_name', namePrefill.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefill.email && !data.email) {
|
||||||
|
setData('email', prefill.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestedUsername && !data.username) {
|
||||||
|
setData('username', suggestedUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefillApplied(true);
|
||||||
|
}, [prefill, namePrefill.first, namePrefill.last, data.first_name, data.last_name, data.email, data.username, prefillApplied, setData, suggestedUsername]);
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
const submit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHasTriedSubmit(true);
|
setHasTriedSubmit(true);
|
||||||
@@ -95,6 +157,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
redirect: json?.redirect ?? null,
|
redirect: json?.redirect ?? null,
|
||||||
pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false,
|
pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false,
|
||||||
});
|
});
|
||||||
|
onClearGoogleProfile?.();
|
||||||
reset();
|
reset();
|
||||||
setHasTriedSubmit(false);
|
setHasTriedSubmit(false);
|
||||||
return;
|
return;
|
||||||
@@ -362,9 +425,13 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
checked={data.privacy_consent}
|
checked={data.privacy_consent}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setData('privacy_consent', e.target.checked);
|
setData('privacy_consent', e.target.checked);
|
||||||
|
setData('terms', e.target.checked);
|
||||||
if (e.target.checked && errors.privacy_consent) {
|
if (e.target.checked && errors.privacy_consent) {
|
||||||
clearErrors('privacy_consent');
|
clearErrors('privacy_consent');
|
||||||
}
|
}
|
||||||
|
if (e.target.checked && errors.terms) {
|
||||||
|
clearErrors('terms');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded"
|
className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
@@ -379,6 +446,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
</button>.
|
</button>.
|
||||||
</label>
|
</label>
|
||||||
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
|
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
|
||||||
|
{errors.terms && <p className="mt-2 text-sm text-red-600">{errors.terms}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -419,8 +487,3 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Head, usePage } from "@inertiajs/react";
|
import { Head, usePage } from "@inertiajs/react";
|
||||||
import MarketingLayout from "@/layouts/mainWebsite";
|
import MarketingLayout from "@/layouts/mainWebsite";
|
||||||
import type { CheckoutPackage } from "./checkout/types";
|
import type { CheckoutPackage, GoogleProfilePrefill } from "./checkout/types";
|
||||||
import { CheckoutWizard } from "./checkout/CheckoutWizard";
|
import { CheckoutWizard } from "./checkout/CheckoutWizard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
@@ -9,20 +9,28 @@ import { X } from "lucide-react";
|
|||||||
interface CheckoutWizardPageProps {
|
interface CheckoutWizardPageProps {
|
||||||
package: CheckoutPackage;
|
package: CheckoutPackage;
|
||||||
packageOptions: CheckoutPackage[];
|
packageOptions: CheckoutPackage[];
|
||||||
stripePublishableKey: string;
|
|
||||||
paypalClientId: string;
|
|
||||||
privacyHtml: string;
|
privacyHtml: string;
|
||||||
|
googleAuth?: {
|
||||||
|
status?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
profile?: GoogleProfilePrefill | null;
|
||||||
|
};
|
||||||
|
paddle?: {
|
||||||
|
environment?: string | null;
|
||||||
|
client_token?: string | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckoutWizardPage({
|
export default function CheckoutWizardPage({
|
||||||
package: initialPackage,
|
package: initialPackage,
|
||||||
packageOptions,
|
packageOptions,
|
||||||
stripePublishableKey,
|
|
||||||
paypalClientId,
|
|
||||||
privacyHtml,
|
privacyHtml,
|
||||||
|
googleAuth,
|
||||||
|
paddle,
|
||||||
}: CheckoutWizardPageProps) {
|
}: CheckoutWizardPageProps) {
|
||||||
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>();
|
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>();
|
||||||
const currentUser = page.props.auth?.user ?? null;
|
const currentUser = page.props.auth?.user ?? null;
|
||||||
|
const googleProfile = googleAuth?.profile ?? null;
|
||||||
|
|
||||||
|
|
||||||
const dedupedOptions = React.useMemo(() => {
|
const dedupedOptions = React.useMemo(() => {
|
||||||
@@ -58,10 +66,10 @@ export default function CheckoutWizardPage({
|
|||||||
<CheckoutWizard
|
<CheckoutWizard
|
||||||
initialPackage={initialPackage}
|
initialPackage={initialPackage}
|
||||||
packageOptions={dedupedOptions}
|
packageOptions={dedupedOptions}
|
||||||
stripePublishableKey={stripePublishableKey}
|
|
||||||
paypalClientId={paypalClientId}
|
|
||||||
privacyHtml={privacyHtml}
|
privacyHtml={privacyHtml}
|
||||||
initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null}
|
initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null}
|
||||||
|
googleProfile={googleProfile}
|
||||||
|
paddle={paddle ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy } from "react";
|
import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy, useState } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Steps } from "@/components/ui/Steps";
|
import { Steps } from "@/components/ui/Steps";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
|
import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
|
||||||
import type { CheckoutPackage, CheckoutStepId } from "./types";
|
import type { CheckoutPackage, CheckoutStepId, GoogleProfilePrefill } from "./types";
|
||||||
import { PackageStep } from "./steps/PackageStep";
|
import { PackageStep } from "./steps/PackageStep";
|
||||||
import { AuthStep } from "./steps/AuthStep";
|
import { AuthStep } from "./steps/AuthStep";
|
||||||
import { ConfirmationStep } from "./steps/ConfirmationStep";
|
import { ConfirmationStep } from "./steps/ConfirmationStep";
|
||||||
@@ -15,8 +15,6 @@ const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({
|
|||||||
interface CheckoutWizardProps {
|
interface CheckoutWizardProps {
|
||||||
initialPackage: CheckoutPackage;
|
initialPackage: CheckoutPackage;
|
||||||
packageOptions: CheckoutPackage[];
|
packageOptions: CheckoutPackage[];
|
||||||
stripePublishableKey: string;
|
|
||||||
paypalClientId: string;
|
|
||||||
privacyHtml: string;
|
privacyHtml: string;
|
||||||
initialAuthUser?: {
|
initialAuthUser?: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,6 +23,11 @@ interface CheckoutWizardProps {
|
|||||||
pending_purchase?: boolean;
|
pending_purchase?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
initialStep?: CheckoutStepId;
|
initialStep?: CheckoutStepId;
|
||||||
|
googleProfile?: GoogleProfilePrefill | null;
|
||||||
|
paddle?: {
|
||||||
|
environment?: string | null;
|
||||||
|
client_token?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: string; detailsKey: string }[] = [
|
const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: string; detailsKey: string }[] = [
|
||||||
@@ -61,13 +64,34 @@ const PaymentStepFallback: React.FC = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => {
|
const WizardBody: React.FC<{
|
||||||
|
privacyHtml: string;
|
||||||
|
googleProfile?: GoogleProfilePrefill | null;
|
||||||
|
onClearGoogleProfile?: () => void;
|
||||||
|
}> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
|
const {
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
selectedPackage,
|
||||||
|
authUser,
|
||||||
|
isAuthenticated,
|
||||||
|
paymentCompleted,
|
||||||
|
} = useCheckoutWizard();
|
||||||
const progressRef = useRef<HTMLDivElement | null>(null);
|
const progressRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hasMountedRef = useRef(false);
|
const hasMountedRef = useRef(false);
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const isFreeSelected = useMemo(() => {
|
||||||
|
if (!selectedPackage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceValue = Number(selectedPackage.price);
|
||||||
|
return Number.isFinite(priceValue) && priceValue <= 0;
|
||||||
|
}, [selectedPackage]);
|
||||||
|
|
||||||
const stepConfig = useMemo(() =>
|
const stepConfig = useMemo(() =>
|
||||||
baseStepConfig.map(step => ({
|
baseStepConfig.map(step => ({
|
||||||
id: step.id,
|
id: step.id,
|
||||||
@@ -114,7 +138,41 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
});
|
});
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
|
const atLastStep = currentIndex >= stepConfig.length - 1;
|
||||||
|
|
||||||
|
const canProceedToNextStep = useMemo(() => {
|
||||||
|
if (atLastStep) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'package') {
|
||||||
|
return Boolean(selectedPackage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'auth') {
|
||||||
|
return Boolean(isAuthenticated && authUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'payment') {
|
||||||
|
return isFreeSelected || paymentCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]);
|
||||||
|
|
||||||
|
const shouldShowNextButton = useMemo(() => {
|
||||||
|
if (currentStep !== 'payment') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isFreeSelected || paymentCompleted;
|
||||||
|
}, [currentStep, isFreeSelected, paymentCompleted]);
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
|
if (!canProceedToNextStep) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const targetStep = stepConfig[currentIndex + 1]?.id ?? 'end';
|
const targetStep = stepConfig[currentIndex + 1]?.id ?? 'end';
|
||||||
trackEvent({
|
trackEvent({
|
||||||
category: 'marketing_checkout',
|
category: 'marketing_checkout',
|
||||||
@@ -122,7 +180,7 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
name: `${currentStep}->${targetStep}`,
|
name: `${currentStep}->${targetStep}`,
|
||||||
});
|
});
|
||||||
nextStep();
|
nextStep();
|
||||||
}, [currentIndex, currentStep, nextStep, stepConfig, trackEvent]);
|
}, [canProceedToNextStep, currentIndex, currentStep, nextStep, stepConfig, trackEvent]);
|
||||||
|
|
||||||
const handlePrevious = useCallback(() => {
|
const handlePrevious = useCallback(() => {
|
||||||
const targetStep = stepConfig[currentIndex - 1]?.id ?? 'start';
|
const targetStep = stepConfig[currentIndex - 1]?.id ?? 'start';
|
||||||
@@ -151,10 +209,16 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{currentStep === "package" && <PackageStep />}
|
{currentStep === "package" && <PackageStep />}
|
||||||
{currentStep === "auth" && <AuthStep privacyHtml={privacyHtml} />}
|
{currentStep === "auth" && (
|
||||||
|
<AuthStep
|
||||||
|
privacyHtml={privacyHtml}
|
||||||
|
googleProfile={googleProfile ?? undefined}
|
||||||
|
onClearGoogleProfile={onClearGoogleProfile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{currentStep === "payment" && (
|
{currentStep === "payment" && (
|
||||||
<Suspense fallback={<PaymentStepFallback />}>
|
<Suspense fallback={<PaymentStepFallback />}>
|
||||||
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} />
|
<PaymentStep />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
{currentStep === "confirmation" && (
|
{currentStep === "confirmation" && (
|
||||||
@@ -162,13 +226,17 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Button variant="ghost" onClick={handlePrevious} disabled={currentIndex <= 0}>
|
<Button variant="ghost" onClick={handlePrevious} disabled={currentIndex <= 0}>
|
||||||
{t('checkout.back')}
|
{t('checkout.back')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleNext} disabled={currentIndex >= stepConfig.length - 1}>
|
{shouldShowNextButton ? (
|
||||||
{t('checkout.next')}
|
<Button onClick={handleNext} disabled={!canProceedToNextStep}>
|
||||||
</Button>
|
{t('checkout.next')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="h-10 min-w-[128px]" aria-hidden="true" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -177,12 +245,51 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
|||||||
export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
|
export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
|
||||||
initialPackage,
|
initialPackage,
|
||||||
packageOptions,
|
packageOptions,
|
||||||
stripePublishableKey,
|
|
||||||
paypalClientId,
|
|
||||||
privacyHtml,
|
privacyHtml,
|
||||||
initialAuthUser,
|
initialAuthUser,
|
||||||
initialStep,
|
initialStep,
|
||||||
|
googleProfile,
|
||||||
|
paddle,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [storedProfile, setStoredProfile] = useState<GoogleProfilePrefill | null>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = window.localStorage.getItem('checkout-google-profile');
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as GoogleProfilePrefill;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse checkout google profile from storage', error);
|
||||||
|
window.localStorage.removeItem('checkout-google-profile');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!googleProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStoredProfile(googleProfile);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem('checkout-google-profile', JSON.stringify(googleProfile));
|
||||||
|
}
|
||||||
|
}, [googleProfile]);
|
||||||
|
|
||||||
|
const clearStoredProfile = useCallback(() => {
|
||||||
|
setStoredProfile(null);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.removeItem('checkout-google-profile');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const effectiveProfile = googleProfile ?? storedProfile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CheckoutWizardProvider
|
<CheckoutWizardProvider
|
||||||
@@ -191,8 +298,13 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
|
|||||||
initialStep={initialStep}
|
initialStep={initialStep}
|
||||||
initialAuthUser={initialAuthUser ?? undefined}
|
initialAuthUser={initialAuthUser ?? undefined}
|
||||||
initialIsAuthenticated={Boolean(initialAuthUser)}
|
initialIsAuthenticated={Boolean(initialAuthUser)}
|
||||||
|
paddle={paddle ?? null}
|
||||||
>
|
>
|
||||||
<WizardBody stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} privacyHtml={privacyHtml} />
|
<WizardBody
|
||||||
|
privacyHtml={privacyHtml}
|
||||||
|
googleProfile={effectiveProfile}
|
||||||
|
onClearGoogleProfile={clearStoredProfile}
|
||||||
|
/>
|
||||||
</CheckoutWizardProvider>
|
</CheckoutWizardProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface CheckoutState {
|
|||||||
paymentIntent: string | null;
|
paymentIntent: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
paymentCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CheckoutWizardContextType {
|
interface CheckoutWizardContextType {
|
||||||
@@ -19,6 +20,11 @@ interface CheckoutWizardContextType {
|
|||||||
currentStep: CheckoutStepId;
|
currentStep: CheckoutStepId;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
authUser: any;
|
authUser: any;
|
||||||
|
paddleConfig?: {
|
||||||
|
environment?: string | null;
|
||||||
|
client_token?: string | null;
|
||||||
|
} | null;
|
||||||
|
paymentCompleted: boolean;
|
||||||
selectPackage: (pkg: CheckoutPackage) => void;
|
selectPackage: (pkg: CheckoutPackage) => void;
|
||||||
setSelectedPackage: (pkg: CheckoutPackage) => void;
|
setSelectedPackage: (pkg: CheckoutPackage) => void;
|
||||||
setAuthUser: (user: any) => void;
|
setAuthUser: (user: any) => void;
|
||||||
@@ -31,6 +37,7 @@ interface CheckoutWizardContextType {
|
|||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
resetPaymentState: () => void;
|
resetPaymentState: () => void;
|
||||||
|
setPaymentCompleted: (completed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
|
const CheckoutWizardContext = createContext<CheckoutWizardContextType | null>(null);
|
||||||
@@ -44,6 +51,7 @@ const initialState: CheckoutState = {
|
|||||||
paymentIntent: null,
|
paymentIntent: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
paymentCompleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type CheckoutAction =
|
type CheckoutAction =
|
||||||
@@ -54,14 +62,15 @@ type CheckoutAction =
|
|||||||
| { type: 'GO_TO_STEP'; payload: CheckoutStepId }
|
| { type: 'GO_TO_STEP'; payload: CheckoutStepId }
|
||||||
| { type: 'UPDATE_PAYMENT_INTENT'; payload: string | null }
|
| { type: 'UPDATE_PAYMENT_INTENT'; payload: string | null }
|
||||||
| { type: 'SET_LOADING'; payload: boolean }
|
| { type: 'SET_LOADING'; payload: boolean }
|
||||||
| { type: 'SET_ERROR'; payload: string | null };
|
| { type: 'SET_ERROR'; payload: string | null }
|
||||||
|
| { type: 'SET_PAYMENT_COMPLETED'; payload: boolean };
|
||||||
|
|
||||||
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
|
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'SELECT_PACKAGE':
|
case 'SELECT_PACKAGE':
|
||||||
return { ...state, selectedPackage: action.payload };
|
return { ...state, selectedPackage: action.payload, paymentCompleted: false };
|
||||||
case 'SET_AUTH_USER':
|
case 'SET_AUTH_USER':
|
||||||
return { ...state, authUser: action.payload };
|
return { ...state, authUser: action.payload, isAuthenticated: Boolean(action.payload) };
|
||||||
case 'NEXT_STEP':
|
case 'NEXT_STEP':
|
||||||
const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
|
const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
|
||||||
const currentIndex = steps.indexOf(state.currentStep);
|
const currentIndex = steps.indexOf(state.currentStep);
|
||||||
@@ -84,6 +93,8 @@ function checkoutReducer(state: CheckoutState, action: CheckoutAction): Checkout
|
|||||||
return { ...state, loading: action.payload };
|
return { ...state, loading: action.payload };
|
||||||
case 'SET_ERROR':
|
case 'SET_ERROR':
|
||||||
return { ...state, error: action.payload };
|
return { ...state, error: action.payload };
|
||||||
|
case 'SET_PAYMENT_COMPLETED':
|
||||||
|
return { ...state, paymentCompleted: action.payload };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -96,6 +107,10 @@ interface CheckoutWizardProviderProps {
|
|||||||
initialStep?: CheckoutStepId;
|
initialStep?: CheckoutStepId;
|
||||||
initialAuthUser?: any;
|
initialAuthUser?: any;
|
||||||
initialIsAuthenticated?: boolean;
|
initialIsAuthenticated?: boolean;
|
||||||
|
paddle?: {
|
||||||
|
environment?: string | null;
|
||||||
|
client_token?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CheckoutWizardProvider({
|
export function CheckoutWizardProvider({
|
||||||
@@ -104,7 +119,8 @@ export function CheckoutWizardProvider({
|
|||||||
packageOptions,
|
packageOptions,
|
||||||
initialStep,
|
initialStep,
|
||||||
initialAuthUser,
|
initialAuthUser,
|
||||||
initialIsAuthenticated
|
initialIsAuthenticated,
|
||||||
|
paddle,
|
||||||
}: CheckoutWizardProviderProps) {
|
}: CheckoutWizardProviderProps) {
|
||||||
const customInitialState: CheckoutState = {
|
const customInitialState: CheckoutState = {
|
||||||
...initialState,
|
...initialState,
|
||||||
@@ -124,7 +140,8 @@ export function CheckoutWizardProvider({
|
|||||||
if (savedState) {
|
if (savedState) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedState);
|
const parsed = JSON.parse(savedState);
|
||||||
if (parsed.selectedPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') {
|
const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.paddle_price_id === 'string' && parsed.selectedPackage.paddle_price_id !== '';
|
||||||
|
if (hasValidPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') {
|
||||||
// Restore state selectively
|
// Restore state selectively
|
||||||
if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
|
if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
|
||||||
if (parsed.currentStep) dispatch({ type: 'GO_TO_STEP', payload: parsed.currentStep });
|
if (parsed.currentStep) dispatch({ type: 'GO_TO_STEP', payload: parsed.currentStep });
|
||||||
@@ -192,6 +209,11 @@ export function CheckoutWizardProvider({
|
|||||||
dispatch({ type: 'UPDATE_PAYMENT_INTENT', payload: null });
|
dispatch({ type: 'UPDATE_PAYMENT_INTENT', payload: null });
|
||||||
dispatch({ type: 'SET_LOADING', payload: false });
|
dispatch({ type: 'SET_LOADING', payload: false });
|
||||||
dispatch({ type: 'SET_ERROR', payload: null });
|
dispatch({ type: 'SET_ERROR', payload: null });
|
||||||
|
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPaymentCompleted = useCallback((completed: boolean) => {
|
||||||
|
dispatch({ type: 'SET_PAYMENT_COMPLETED', payload: completed });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cancelCheckout = useCallback(() => {
|
const cancelCheckout = useCallback(() => {
|
||||||
@@ -226,6 +248,8 @@ export function CheckoutWizardProvider({
|
|||||||
currentStep: state.currentStep,
|
currentStep: state.currentStep,
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
authUser: state.authUser,
|
authUser: state.authUser,
|
||||||
|
paddleConfig: paddle ?? null,
|
||||||
|
paymentCompleted: state.paymentCompleted,
|
||||||
selectPackage,
|
selectPackage,
|
||||||
setSelectedPackage,
|
setSelectedPackage,
|
||||||
setAuthUser,
|
setAuthUser,
|
||||||
@@ -238,6 +262,7 @@ export function CheckoutWizardProvider({
|
|||||||
setLoading,
|
setLoading,
|
||||||
setError,
|
setError,
|
||||||
resetPaymentState,
|
resetPaymentState,
|
||||||
|
setPaymentCompleted,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { usePage } from "@inertiajs/react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { useCheckoutWizard } from "../WizardContext";
|
import { useCheckoutWizard } from "../WizardContext";
|
||||||
|
import type { GoogleProfilePrefill } from '../types';
|
||||||
import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
|
import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm";
|
||||||
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
|
import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -11,6 +12,8 @@ import { LoaderCircle } from "lucide-react";
|
|||||||
|
|
||||||
interface AuthStepProps {
|
interface AuthStepProps {
|
||||||
privacyHtml: string;
|
privacyHtml: string;
|
||||||
|
googleProfile?: GoogleProfilePrefill;
|
||||||
|
onClearGoogleProfile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GoogleAuthFlash = {
|
type GoogleAuthFlash = {
|
||||||
@@ -29,7 +32,7 @@ const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile, onClearGoogleProfile }) => {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const page = usePage<{ locale?: string }>();
|
const page = usePage<{ locale?: string }>();
|
||||||
const locale = page.props.locale ?? "de";
|
const locale = page.props.locale ?? "de";
|
||||||
@@ -42,10 +45,11 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
|||||||
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (googleAuth?.status === 'success') {
|
if (googleAuth?.status === 'signin') {
|
||||||
toast.success(t('checkout.auth_step.google_success_toast'));
|
toast.success(t('checkout.auth_step.google_success_toast'));
|
||||||
|
onClearGoogleProfile?.();
|
||||||
}
|
}
|
||||||
}, [googleAuth?.status, t]);
|
}, [googleAuth?.status, onClearGoogleProfile, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (googleAuth?.error) {
|
if (googleAuth?.error) {
|
||||||
@@ -64,6 +68,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
|||||||
name: payload.name ?? undefined,
|
name: payload.name ?? undefined,
|
||||||
pending_purchase: Boolean(payload.pending_purchase),
|
pending_purchase: Boolean(payload.pending_purchase),
|
||||||
});
|
});
|
||||||
|
onClearGoogleProfile?.();
|
||||||
nextStep();
|
nextStep();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClearGoogleProfile?.();
|
||||||
nextStep();
|
nextStep();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,6 +164,8 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
|
|||||||
privacyHtml={privacyHtml}
|
privacyHtml={privacyHtml}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onSuccess={handleRegisterSuccess}
|
onSuccess={handleRegisterSuccess}
|
||||||
|
prefill={googleProfile}
|
||||||
|
onClearGoogleProfile={onClearGoogleProfile}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,401 +1,332 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
|
|
||||||
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { useCheckoutWizard } from '../WizardContext';
|
import { useCheckoutWizard } from '../WizardContext';
|
||||||
import { getStripe } from '@/utils/stripe';
|
|
||||||
|
|
||||||
interface PaymentStepProps {
|
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
|
||||||
stripePublishableKey: string;
|
|
||||||
paypalClientId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Provider = 'stripe' | 'paypal';
|
declare global {
|
||||||
type PaymentStatus = 'idle' | 'loading' | 'ready' | 'processing' | 'error' | 'success';
|
interface Window {
|
||||||
|
Paddle?: {
|
||||||
interface StripePaymentFormProps {
|
Environment?: {
|
||||||
onProcessing: () => void;
|
set: (environment: string) => void;
|
||||||
onSuccess: () => void;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
selectedPackage: any;
|
|
||||||
t: (key: string, options?: Record<string, unknown>) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StripePaymentForm: React.FC<StripePaymentFormProps> = ({ onProcessing, onSuccess, onError, selectedPackage, t }) => {
|
|
||||||
const stripe = useStripe();
|
|
||||||
const elements = useElements();
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!stripe || !elements) {
|
|
||||||
const message = t('checkout.payment_step.stripe_not_loaded');
|
|
||||||
onError(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onProcessing();
|
|
||||||
setIsProcessing(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error: stripeError, paymentIntent } = await stripe.confirmPayment({
|
|
||||||
elements,
|
|
||||||
confirmParams: {
|
|
||||||
return_url: `${window.location.origin}/checkout/success`,
|
|
||||||
},
|
|
||||||
redirect: 'if_required',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stripeError) {
|
|
||||||
let message = t('checkout.payment_step.payment_failed');
|
|
||||||
|
|
||||||
switch (stripeError.type) {
|
|
||||||
case 'card_error':
|
|
||||||
message += stripeError.message || t('checkout.payment_step.error_card');
|
|
||||||
break;
|
|
||||||
case 'validation_error':
|
|
||||||
message += t('checkout.payment_step.error_validation');
|
|
||||||
break;
|
|
||||||
case 'api_connection_error':
|
|
||||||
message += t('checkout.payment_step.error_connection');
|
|
||||||
break;
|
|
||||||
case 'api_error':
|
|
||||||
message += t('checkout.payment_step.error_server');
|
|
||||||
break;
|
|
||||||
case 'authentication_error':
|
|
||||||
message += t('checkout.payment_step.error_auth');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
message += stripeError.message || t('checkout.payment_step.error_unknown');
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage(message);
|
|
||||||
onError(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paymentIntent && paymentIntent.status === 'succeeded') {
|
|
||||||
onSuccess();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onError(t('checkout.payment_step.unexpected_status', { status: paymentIntent?.status }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Stripe payment failed', error);
|
|
||||||
onError(t('checkout.payment_step.error_unknown'));
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
Initialize?: (options: { token: string }) => void;
|
||||||
|
Checkout: {
|
||||||
|
open: (options: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isReseller) {
|
const PADDLE_SCRIPT_URL = 'https://cdn.paddle.com/paddle/v2/paddle.js';
|
||||||
if (!paypalPlanId) {
|
|
||||||
const message = t('checkout.payment_step.paypal_missing_plan');
|
|
||||||
onError(message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
payload.plan_id = paypalPlanId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
type PaddleEnvironment = 'sandbox' | 'production';
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
let paddleLoaderPromise: Promise<typeof window.Paddle | null> | null = null;
|
||||||
|
|
||||||
if (response.ok) {
|
function configurePaddle(paddle: typeof window.Paddle | undefined | null, environment: PaddleEnvironment): typeof window.Paddle | null {
|
||||||
const orderId = isReseller ? data.order_id : data.id;
|
if (!paddle) {
|
||||||
if (typeof orderId === 'string' && orderId.length > 0) {
|
return null;
|
||||||
return orderId;
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onError(data.error || t('checkout.payment_step.paypal_order_error'));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Failed to create PayPal order');
|
try {
|
||||||
} catch (error) {
|
paddle.Environment?.set?.(environment);
|
||||||
console.error('PayPal create order failed', error);
|
} catch (error) {
|
||||||
onError(t('checkout.payment_step.network_error'));
|
console.warn('[Paddle] Failed to set environment', error);
|
||||||
throw error;
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onApprove = async (data: any) => {
|
return paddle;
|
||||||
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 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
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') {
|
if (window.Paddle) {
|
||||||
onSuccess();
|
return configurePaddle(window.Paddle, environment);
|
||||||
} 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'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (error: unknown) => {
|
if (!paddleLoaderPromise) {
|
||||||
console.error('PayPal error', error);
|
paddleLoaderPromise = new Promise<typeof window.Paddle | null>((resolve, reject) => {
|
||||||
onError(t('checkout.payment_step.paypal_error'));
|
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 = () => {
|
const paddle = await paddleLoaderPromise;
|
||||||
onError(t('checkout.payment_step.paypal_cancelled'));
|
|
||||||
};
|
return configurePaddle(paddle, environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaddleCta: React.FC<{ onCheckout: () => Promise<void>; disabled: boolean; isProcessing: boolean }> = ({ onCheckout, disabled, isProcessing }) => {
|
||||||
|
const { t } = useTranslation('marketing');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
<Button size="lg" className="w-full" disabled={disabled} onClick={onCheckout}>
|
||||||
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.secure_paypal_desc') || 'Bezahlen Sie sicher mit PayPal.'}</p>
|
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
<PayPalButtons
|
{t('checkout.payment_step.pay_with_paddle')}
|
||||||
style={{ layout: 'vertical' }}
|
</Button>
|
||||||
createOrder={async () => createOrder()}
|
|
||||||
onApprove={onApprove}
|
|
||||||
onError={handleError}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusVariantMap: Record<PaymentStatus, 'default' | 'destructive' | 'success' | 'secondary'> = {
|
export const PaymentStep: React.FC = () => {
|
||||||
idle: 'secondary',
|
|
||||||
loading: 'secondary',
|
|
||||||
ready: 'secondary',
|
|
||||||
processing: 'secondary',
|
|
||||||
error: 'destructive',
|
|
||||||
success: 'success',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey, paypalClientId }) => {
|
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { selectedPackage, authUser, nextStep, resetPaymentState } = useCheckoutWizard();
|
const { selectedPackage, nextStep, paddleConfig, authUser, setPaymentCompleted } = useCheckoutWizard();
|
||||||
|
|
||||||
const [paymentMethod, setPaymentMethod] = useState<Provider>('stripe');
|
|
||||||
const [clientSecret, setClientSecret] = useState('');
|
|
||||||
const [status, setStatus] = useState<PaymentStatus>('idle');
|
const [status, setStatus] = useState<PaymentStatus>('idle');
|
||||||
const [statusDetail, setStatusDetail] = useState<string>('');
|
const [message, setMessage] = useState<string>('');
|
||||||
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
|
const [initialised, setInitialised] = useState(false);
|
||||||
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null);
|
const [inlineActive, setInlineActive] = useState(false);
|
||||||
|
const paddleRef = useRef<typeof window.Paddle | null>(null);
|
||||||
|
const eventCallbackRef = useRef<(event: any) => void>();
|
||||||
|
const checkoutContainerClass = 'paddle-checkout-container';
|
||||||
|
|
||||||
const stripePromise = useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
|
const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]);
|
||||||
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
|
|
||||||
const isReseller = selectedPackage?.type === 'reseller';
|
|
||||||
|
|
||||||
const paypalPlanId = useMemo(() => {
|
const handleFreeActivation = async () => {
|
||||||
if (!selectedPackage) {
|
setPaymentCompleted(true);
|
||||||
return null;
|
nextStep();
|
||||||
}
|
};
|
||||||
|
|
||||||
if (typeof selectedPackage.paypal_plan_id === 'string' && selectedPackage.paypal_plan_id.trim().length > 0) {
|
|
||||||
return selectedPackage.paypal_plan_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = (selectedPackage as Record<string, unknown>)?.metadata;
|
|
||||||
if (metadata && typeof metadata === 'object') {
|
|
||||||
const value = (metadata as Record<string, unknown>).paypal_plan_id;
|
|
||||||
if (typeof value === 'string' && value.trim().length > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [selectedPackage]);
|
|
||||||
|
|
||||||
const paypalDisabled = isReseller && !paypalPlanId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStatus('idle');
|
|
||||||
setStatusDetail('');
|
|
||||||
setClientSecret('');
|
|
||||||
setProcessingProvider(null);
|
|
||||||
}, [selectedPackage?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isFree) {
|
|
||||||
resetPaymentState();
|
|
||||||
setStatus('ready');
|
|
||||||
setStatusDetail('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const startPaddleCheckout = async () => {
|
||||||
if (!selectedPackage) {
|
if (!selectedPackage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paymentMethod === 'paypal') {
|
if (!selectedPackage.paddle_price_id) {
|
||||||
if (paypalDisabled) {
|
|
||||||
setStatus('error');
|
|
||||||
setStatusDetail(t('checkout.payment_step.paypal_missing_plan'));
|
|
||||||
} else {
|
|
||||||
setStatus('ready');
|
|
||||||
setStatusDetail('');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stripePromise) {
|
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setStatusDetail(t('checkout.payment_step.stripe_not_loaded'));
|
setMessage(t('checkout.payment_step.paddle_not_configured'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authUser) {
|
setPaymentCompleted(false);
|
||||||
setStatus('error');
|
setStatus('processing');
|
||||||
setStatusDetail(t('checkout.payment_step.auth_required'));
|
setMessage(t('checkout.payment_step.paddle_preparing'));
|
||||||
return;
|
setInlineActive(false);
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
try {
|
||||||
setStatus('loading');
|
const inlineSupported = initialised && !!paddleConfig?.client_token;
|
||||||
setStatusDetail(t('checkout.payment_step.status_loading'));
|
|
||||||
setClientSecret('');
|
|
||||||
|
|
||||||
const loadIntent = async () => {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
// eslint-disable-next-line no-console
|
||||||
const response = await fetch('/stripe/create-payment-intent', {
|
console.info('[Checkout] Paddle inline status', {
|
||||||
method: 'POST',
|
inlineSupported,
|
||||||
headers: {
|
initialised,
|
||||||
'Content-Type': 'application/json',
|
hasClientToken: Boolean(paddleConfig?.client_token),
|
||||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
environment: paddleConfig?.environment,
|
||||||
},
|
paddlePriceId: selectedPackage.paddle_price_id,
|
||||||
body: JSON.stringify({ package_id: selectedPackage.id }),
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
if (inlineSupported) {
|
||||||
|
const paddle = paddleRef.current;
|
||||||
|
|
||||||
if (!response.ok || !data.client_secret) {
|
if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') {
|
||||||
const message = data.error || t('checkout.payment_step.payment_intent_error');
|
throw new Error('Inline Paddle checkout is not available.');
|
||||||
if (!cancelled) {
|
}
|
||||||
setStatus('error');
|
|
||||||
setStatusDetail(message);
|
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) {
|
if (!response.ok || !checkoutUrl) {
|
||||||
setClientSecret(data.client_secret);
|
const message = data?.message || rawBody || 'Unable to create Paddle checkout.';
|
||||||
setStatus('ready');
|
throw new Error(message);
|
||||||
setStatusDetail(t('checkout.payment_step.status_ready'));
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
window.open(checkoutUrl, '_blank', 'noopener');
|
||||||
if (!cancelled) {
|
setInlineActive(false);
|
||||||
console.error('Failed to load payment intent', error);
|
setStatus('ready');
|
||||||
setStatus('error');
|
setMessage(t('checkout.payment_step.paddle_ready'));
|
||||||
setStatusDetail(t('checkout.payment_step.network_error'));
|
} 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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, stripePromise, t]);
|
}, [paddleConfig?.environment, paddleConfig?.client_token, setPaymentCompleted, t]);
|
||||||
|
|
||||||
const providerLabel = useCallback((provider: Provider) => {
|
useEffect(() => {
|
||||||
switch (provider) {
|
setPaymentCompleted(false);
|
||||||
case 'paypal':
|
}, [selectedPackage?.id, setPaymentCompleted]);
|
||||||
return 'PayPal';
|
|
||||||
default:
|
|
||||||
return 'Stripe';
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleProcessing = useCallback((provider: Provider) => {
|
if (!selectedPackage) {
|
||||||
setProcessingProvider(provider);
|
return (
|
||||||
setStatus('processing');
|
<Alert variant="destructive">
|
||||||
setStatusDetail(t('checkout.payment_step.status_processing', { provider: providerLabel(provider) }));
|
<AlertTitle>{t('checkout.payment_step.no_package_title')}</AlertTitle>
|
||||||
}, [providerLabel, t]);
|
<AlertDescription>{t('checkout.payment_step.no_package_description')}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
const handleSuccess = useCallback((provider: Provider) => {
|
);
|
||||||
setProcessingProvider(provider);
|
}
|
||||||
setStatus('success');
|
|
||||||
setStatusDetail(t('checkout.payment_step.status_success'));
|
|
||||||
setTimeout(() => nextStep(), 600);
|
|
||||||
}, [nextStep, t]);
|
|
||||||
|
|
||||||
const handleError = useCallback((provider: Provider, message: string) => {
|
|
||||||
setProcessingProvider(provider);
|
|
||||||
setStatus('error');
|
|
||||||
setStatusDetail(message);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRetry = () => {
|
|
||||||
if (paymentMethod === 'stripe') {
|
|
||||||
setIntentRefreshKey((key) => key + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('idle');
|
|
||||||
setStatusDetail('');
|
|
||||||
setProcessingProvider(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isFree) {
|
if (isFree) {
|
||||||
return (
|
return (
|
||||||
@@ -405,7 +336,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
|||||||
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
|
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button size="lg" onClick={nextStep}>
|
<Button size="lg" onClick={handleFreeActivation}>
|
||||||
{t('checkout.payment_step.activate_package')}
|
{t('checkout.payment_step.activate_package')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,94 +344,44 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderStatusAlert = () => {
|
|
||||||
if (status === 'idle') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variant = statusVariantMap[status];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert variant={variant}>
|
|
||||||
<AlertTitle>
|
|
||||||
{status === 'error'
|
|
||||||
? t('checkout.payment_step.status_error_title')
|
|
||||||
: status === 'success'
|
|
||||||
? t('checkout.payment_step.status_success_title')
|
|
||||||
: t('checkout.payment_step.status_info_title')}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription className="flex items-center justify-between gap-4">
|
|
||||||
<span>{statusDetail}</span>
|
|
||||||
{status === 'processing' && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
|
||||||
{status === 'error' && (
|
|
||||||
<Button size="sm" variant="outline" onClick={handleRetry}>
|
|
||||||
{t('checkout.payment_step.status_retry')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap gap-3">
|
<p className="text-sm text-muted-foreground">
|
||||||
<Button
|
{t('checkout.payment_step.paddle_intro')}
|
||||||
variant={paymentMethod === 'stripe' ? 'default' : 'outline'}
|
</p>
|
||||||
onClick={() => setPaymentMethod('stripe')}
|
|
||||||
disabled={paymentMethod === 'stripe'}
|
|
||||||
>
|
|
||||||
{t('checkout.payment_step.method_stripe')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={paymentMethod === 'paypal' ? 'default' : 'outline'}
|
|
||||||
onClick={() => setPaymentMethod('paypal')}
|
|
||||||
disabled={paypalDisabled || paymentMethod === 'paypal'}
|
|
||||||
>
|
|
||||||
{t('checkout.payment_step.method_paypal')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderStatusAlert()}
|
{status !== 'idle' && (
|
||||||
|
<Alert variant={status === 'error' ? 'destructive' : 'secondary'}>
|
||||||
{paymentMethod === 'stripe' && clientSecret && stripePromise && (
|
<AlertTitle>
|
||||||
<Elements stripe={stripePromise} options={{ clientSecret }}>
|
{status === 'processing'
|
||||||
<StripePaymentForm
|
? t('checkout.payment_step.status_processing_title')
|
||||||
selectedPackage={selectedPackage}
|
: status === 'ready'
|
||||||
onProcessing={() => handleProcessing('stripe')}
|
? t('checkout.payment_step.status_ready_title')
|
||||||
onSuccess={() => handleSuccess('stripe')}
|
: status === 'error'
|
||||||
onError={(message) => handleError('stripe', message)}
|
? t('checkout.payment_step.status_error_title')
|
||||||
t={t}
|
: t('checkout.payment_step.status_info_title')}
|
||||||
/>
|
</AlertTitle>
|
||||||
</Elements>
|
<AlertDescription className="flex items-center gap-3">
|
||||||
)}
|
<span>{message}</span>
|
||||||
|
{status === 'processing' && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||||
{paymentMethod === 'stripe' && !clientSecret && status === 'loading' && (
|
</AlertDescription>
|
||||||
<div className="rounded-lg border bg-card p-6 text-sm text-muted-foreground shadow-sm">
|
|
||||||
<LoaderCircle className="mb-3 h-4 w-4 animate-spin" />
|
|
||||||
{t('checkout.payment_step.status_loading')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{paymentMethod === 'paypal' && !paypalDisabled && (
|
|
||||||
<PayPalScriptProvider options={{ clientId: paypalClientId, currency: 'EUR' }}>
|
|
||||||
<PayPalPaymentForm
|
|
||||||
isReseller={Boolean(isReseller)}
|
|
||||||
onProcessing={() => handleProcessing('paypal')}
|
|
||||||
onSuccess={() => handleSuccess('paypal')}
|
|
||||||
onError={(message) => handleError('paypal', message)}
|
|
||||||
paypalPlanId={paypalPlanId}
|
|
||||||
selectedPackage={selectedPackage}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</PayPalScriptProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{paymentMethod === 'paypal' && paypalDisabled && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{t('checkout.payment_step.paypal_missing_plan')}</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
|
||||||
|
<div className={`${checkoutContainerClass} min-h-[200px]`} />
|
||||||
|
{!inlineActive && (
|
||||||
|
<PaddleCta
|
||||||
|
onCheckout={startPaddleCheckout}
|
||||||
|
disabled={status === 'processing'}
|
||||||
|
isProcessing={status === 'processing'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('checkout.payment_step.paddle_disclaimer')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
export type CheckoutStepId = 'package' | 'auth' | 'payment' | 'confirmation';
|
export type CheckoutStepId = 'package' | 'auth' | 'payment' | 'confirmation';
|
||||||
|
|
||||||
|
export interface GoogleProfilePrefill {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
avatar?: string;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CheckoutPackage {
|
export interface CheckoutPackage {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,7 +24,8 @@ export interface CheckoutPackage {
|
|||||||
type: 'endcustomer' | 'reseller';
|
type: 'endcustomer' | 'reseller';
|
||||||
features: string[];
|
features: string[];
|
||||||
limits?: Record<string, unknown>;
|
limits?: Record<string, unknown>;
|
||||||
paypal_plan_id?: string | null;
|
paddle_price_id?: string | null;
|
||||||
|
paddle_product_id?: string | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +40,7 @@ export interface CheckoutWizardState {
|
|||||||
name?: string;
|
name?: string;
|
||||||
pending_purchase?: boolean;
|
pending_purchase?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
paymentProvider?: 'stripe' | 'paypal';
|
paymentProvider?: 'stripe' | 'paddle';
|
||||||
isProcessing?: boolean;
|
isProcessing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ return [
|
|||||||
'contact' => 'Kontakt',
|
'contact' => 'Kontakt',
|
||||||
'vat_id' => 'Umsatzsteuer-ID: DE123456789',
|
'vat_id' => 'Umsatzsteuer-ID: DE123456789',
|
||||||
'monetization' => 'Monetarisierung',
|
'monetization' => 'Monetarisierung',
|
||||||
'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de',
|
'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Paddle. Preise exkl. MwSt. Support: support@fotospiel.de',
|
||||||
'register_court' => 'Registergericht: Amtsgericht Musterstadt',
|
'register_court' => 'Registergericht: Amtsgericht Musterstadt',
|
||||||
'commercial_register' => 'Handelsregister: HRB 12345',
|
'commercial_register' => 'Handelsregister: HRB 12345',
|
||||||
'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.',
|
'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.',
|
||||||
'responsible' => 'Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt',
|
'responsible' => 'Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt',
|
||||||
'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.',
|
'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.',
|
||||||
'payments' => 'Zahlungen und Packages',
|
'payments' => 'Zahlungen und Packages',
|
||||||
'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert – alle Daten werden verschlüsselt übertragen.',
|
'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsinformationen werden sicher und verschlüsselt durch Paddle als Merchant of Record verarbeitet.',
|
||||||
'data_retention' => 'Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.',
|
'data_retention' => 'Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.',
|
||||||
'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.',
|
'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.',
|
||||||
'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.',
|
'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.',
|
||||||
@@ -34,5 +34,4 @@ return [
|
|||||||
'version' => 'Version :version',
|
'version' => 'Version :version',
|
||||||
'and' => 'und',
|
'and' => 'und',
|
||||||
'stripe_privacy' => 'Stripe Datenschutz',
|
'stripe_privacy' => 'Stripe Datenschutz',
|
||||||
'paypal_privacy' => 'PayPal Datenschutz',
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"faq_q3": "Was passiert bei Ablauf?",
|
"faq_q3": "Was passiert bei Ablauf?",
|
||||||
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
|
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
|
||||||
"faq_q4": "Zahlungssicher?",
|
"faq_q4": "Zahlungssicher?",
|
||||||
"faq_a4": "Ja, via Stripe oder PayPal – sicher und GDPR-konform.",
|
"faq_a4": "Ja, via Paddle – sicher und GDPR-konform.",
|
||||||
"final_cta": "Bereit für Ihr nächstes Event?",
|
"final_cta": "Bereit für Ihr nächstes Event?",
|
||||||
"contact_us": "Kontaktieren Sie uns",
|
"contact_us": "Kontaktieren Sie uns",
|
||||||
"feature_live_slideshow": "Live-Slideshow",
|
"feature_live_slideshow": "Live-Slideshow",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ return [
|
|||||||
'faq_q3' => 'Was passiert bei Ablauf?',
|
'faq_q3' => 'Was passiert bei Ablauf?',
|
||||||
'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.',
|
'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.',
|
||||||
'faq_q4' => 'Zahlungssicher?',
|
'faq_q4' => 'Zahlungssicher?',
|
||||||
'faq_a4' => 'Ja, via Stripe oder PayPal – sicher und GDPR-konform.',
|
'faq_a4' => 'Ja, via Paddle – sicher und GDPR-konform.',
|
||||||
'final_cta' => 'Bereit für Ihr nächstes Event?',
|
'final_cta' => 'Bereit für Ihr nächstes Event?',
|
||||||
'contact_us' => 'Kontaktieren Sie uns',
|
'contact_us' => 'Kontaktieren Sie uns',
|
||||||
'feature_live_slideshow' => 'Live-Slideshow',
|
'feature_live_slideshow' => 'Live-Slideshow',
|
||||||
@@ -60,10 +60,12 @@ return [
|
|||||||
'max_guests_label' => 'Max. Gäste',
|
'max_guests_label' => 'Max. Gäste',
|
||||||
'gallery_days_label' => 'Galerie-Tage',
|
'gallery_days_label' => 'Galerie-Tage',
|
||||||
'feature_overview' => 'Feature-Überblick',
|
'feature_overview' => 'Feature-Überblick',
|
||||||
'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung via Stripe oder PayPal.',
|
'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Paddle.',
|
||||||
'features_label' => 'Features',
|
'features_label' => 'Features',
|
||||||
'breakdown_label' => 'Leistungsübersicht',
|
'breakdown_label' => 'Leistungsübersicht',
|
||||||
'limits_label' => 'Limits & Kapazitäten',
|
'limits_label' => 'Limits & Kapazitäten',
|
||||||
|
'paddle_not_configured' => 'Dieses Package ist noch nicht für den Paddle-Checkout konfiguriert. Bitte kontaktiere den Support.',
|
||||||
|
'paddle_checkout_failed' => 'Der Paddle-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.',
|
||||||
],
|
],
|
||||||
'nav' => [
|
'nav' => [
|
||||||
'home' => 'Startseite',
|
'home' => 'Startseite',
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ return [
|
|||||||
'contact' => 'Contact',
|
'contact' => 'Contact',
|
||||||
'vat_id' => 'VAT ID: DE123456789',
|
'vat_id' => 'VAT ID: DE123456789',
|
||||||
'monetization' => 'Monetization',
|
'monetization' => 'Monetization',
|
||||||
'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Stripe and PayPal. Prices excl. VAT. Support: support@fotospiel.de',
|
'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Paddle. Prices excl. VAT. Support: support@fotospiel.de',
|
||||||
'register_court' => 'Register Court: District Court Musterstadt',
|
'register_court' => 'Register Court: District Court Musterstadt',
|
||||||
'commercial_register' => 'Commercial Register: HRB 12345',
|
'commercial_register' => 'Commercial Register: HRB 12345',
|
||||||
'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.',
|
'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.',
|
||||||
'responsible' => 'Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt',
|
'responsible' => 'Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt',
|
||||||
'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.',
|
'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.',
|
||||||
'payments' => 'Payments and Packages',
|
'payments' => 'Payments and Packages',
|
||||||
'payments_desc' => 'We process payments for Packages via Stripe and PayPal. Card information is not stored – all data is transmitted encrypted. See Stripe Privacy and PayPal Privacy.',
|
'payments_desc' => 'We process payments for Packages via Paddle. Payment information is handled securely and encrypted by Paddle as the merchant of record.',
|
||||||
'data_retention' => 'Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.',
|
'data_retention' => 'Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.',
|
||||||
'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.',
|
'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.',
|
||||||
'cookies' => 'Cookies: Only functional cookies for the PWA.',
|
'cookies' => 'Cookies: Only functional cookies for the PWA.',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user