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

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

View File

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

View File

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

View File

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

View File

@@ -20,11 +20,11 @@ class ViewPurchase extends ViewRecord
->color('danger')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn ($record): bool => !$record->refunded)
->visible(fn ($record): bool => ! $record->refunded)
->action(function ($record) {
$record->update(['refunded' => true]);
// TODO: Call Stripe/PayPal API for actual refund
// TODO: Call Stripe/Paddle API for actual refund
}),
];
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,19 +3,18 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
use App\Models\Tenant;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\App;
class RegisteredUserController extends Controller
{
@@ -38,7 +37,7 @@ class RegisteredUserController extends Controller
*/
public function store(Request $request)
{
$fullName = trim($request->first_name . ' ' . $request->last_name);
$fullName = trim($request->first_name.' '.$request->last_name);
$validated = $request->validate([
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
@@ -73,7 +72,7 @@ class RegisteredUserController extends Controller
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $fullName,
'slug' => Str::slug($fullName . '-' . now()->timestamp),
'slug' => Str::slug($fullName.'-'.now()->timestamp),
'email' => $request->email,
'is_active' => true,
'is_suspended' => false,
@@ -123,6 +122,7 @@ class RegisteredUserController extends Controller
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'price' => 0,
'purchased_at' => now(),
'provider' => 'free',
'provider_id' => 'free',
]);
@@ -146,8 +146,3 @@ class RegisteredUserController extends Controller
return Inertia::location(route('verification.notice'));
}
}

View File

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

View File

@@ -2,17 +2,13 @@
namespace App\Http\Controllers;
use App\Mail\Welcome;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -52,56 +48,56 @@ class CheckoutGoogleController extends Controller
} catch (\Throwable $e) {
Log::warning('Google checkout login failed', ['message' => $e->getMessage()]);
$this->flashError($request, __('checkout.google_error_fallback'));
return $this->redirectBackToWizard($packageId);
}
$email = $googleUser->getEmail();
if (! $email) {
$this->flashError($request, __('checkout.google_missing_email'));
return $this->redirectBackToWizard($packageId);
}
$user = DB::transaction(function () use ($googleUser, $email) {
$existing = User::where('email', $email)->first();
$raw = $googleUser->getRaw();
$request->session()->put('checkout_google_profile', array_filter([
'email' => $email,
'name' => $googleUser->getName(),
'given_name' => $raw['given_name'] ?? null,
'family_name' => $raw['family_name'] ?? null,
'avatar' => $googleUser->getAvatar(),
'locale' => $raw['locale'] ?? null,
]));
if ($existing) {
$existing->forceFill([
'name' => $googleUser->getName() ?: $existing->name,
'pending_purchase' => true,
'email_verified_at' => $existing->email_verified_at ?? now(),
])->save();
$existing = User::where('email', $email)->first();
if (! $existing->tenant) {
$this->createTenantForUser($existing, $googleUser->getName(), $email);
}
return $existing->fresh();
}
$user = User::create([
'name' => $googleUser->getName(),
if (! $existing) {
$request->session()->put('checkout_google_profile', array_filter([
'email' => $email,
'password' => Hash::make(Str::random(32)),
'name' => $googleUser->getName(),
'given_name' => $raw['given_name'] ?? null,
'family_name' => $raw['family_name'] ?? null,
'avatar' => $googleUser->getAvatar(),
'locale' => $raw['locale'] ?? null,
]));
$request->session()->put('checkout_google_status', 'prefill');
return $this->redirectBackToWizard($packageId);
}
$user = DB::transaction(function () use ($existing, $googleUser, $email) {
$existing->forceFill([
'name' => $googleUser->getName() ?: $existing->name,
'pending_purchase' => true,
'email_verified_at' => now(),
]);
'email_verified_at' => $existing->email_verified_at ?? now(),
])->save();
event(new Registered($user));
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
try {
Mail::to($user)
->locale($user->preferred_locale ?? app()->getLocale())
->queue(new Welcome($user));
} catch (\Throwable $exception) {
Log::warning('Failed to queue welcome mail after Google signup', [
'user_id' => $user->id,
'error' => $exception->getMessage(),
]);
if (! $existing->tenant) {
$this->createTenantForUser($existing, $googleUser->getName(), $email);
}
return tap($user)->setRelation('tenant', $tenant);
return $existing->fresh();
});
if (! $user->tenant) {
@@ -111,7 +107,8 @@ class CheckoutGoogleController extends Controller
Auth::login($user, true);
$request->session()->regenerate();
$request->session()->forget(self::SESSION_KEY);
$request->session()->put('checkout_google_status', 'success');
$request->session()->forget('checkout_google_profile');
$request->session()->put('checkout_google_status', 'signin');
if ($packageId) {
$this->ensurePackageAttached($user, (int) $packageId);
@@ -128,7 +125,7 @@ class CheckoutGoogleController extends Controller
$counter = 1;
while (Tenant::where('slug', $slug)->exists()) {
$slug = $slugBase . '-' . $counter;
$slug = $slugBase.'-'.$counter;
$counter++;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,16 +15,25 @@ class CheckoutSession extends Model
use SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_AWAITING_METHOD = 'awaiting_payment_method';
public const STATUS_REQUIRES_CUSTOMER_ACTION = 'requires_customer_action';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
public const PROVIDER_NONE = 'none';
public const PROVIDER_STRIPE = 'stripe';
public const PROVIDER_PAYPAL = 'paypal';
public const PROVIDER_PADDLE = 'paddle';
public const PROVIDER_FREE = 'free';
/**
@@ -103,4 +112,4 @@ class CheckoutSession extends Model
{
return $this->status === self::STATUS_REQUIRES_CUSTOMER_ACTION;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,8 @@ trait PresentsPackages
'slug' => $package->slug,
'type' => $package->type,
'price' => $package->price,
'paddle_product_id' => $package->paddle_product_id,
'paddle_price_id' => $package->paddle_price_id,
'description' => $description,
'description_breakdown' => $table,
'gallery_duration_label' => $galleryDuration,
@@ -111,20 +113,20 @@ trait PresentsPackages
{
$locale = app()->getLocale();
if (!$days || $days <= 0) {
if (! $days || $days <= 0) {
return $locale === 'en' ? 'permanent' : 'dauerhaft';
}
if ($days % 30 === 0) {
$months = (int) ($days / 30);
if ($locale === 'en') {
return $months === 1 ? '1 month' : $months . ' months';
return $months === 1 ? '1 month' : $months.' months';
}
return $months === 1 ? '1 Monat' : $months . ' Monate';
return $months === 1 ? '1 Monat' : $months.' Monate';
}
return $locale === 'en' ? $days . ' days' : $days . ' Tage';
return $locale === 'en' ? $days.' days' : $days.' Tage';
}
protected function normaliseFeatures(mixed $features): array
@@ -144,6 +146,7 @@ trait PresentsPackages
foreach ($features as $key => $value) {
if (is_string($value)) {
$list[] = $value;
continue;
}