implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history
This commit is contained in:
147
app/Filament/Resources/PackageAddonResource.php
Normal file
147
app/Filament/Resources/PackageAddonResource.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\PackageAddonResource\Pages;
|
||||
use App\Jobs\SyncPackageAddonToPaddle;
|
||||
use App\Models\PackageAddon;
|
||||
use Filament\Actions;
|
||||
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\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PackageAddonResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PackageAddon::class;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-plus-circle';
|
||||
|
||||
protected static ?int $navigationSort = 6;
|
||||
|
||||
public static function getNavigationGroup(): \BackedEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform_management');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Add-on Details')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->label('Label')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('key')
|
||||
->label('Schlüssel')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(191),
|
||||
TextInput::make('price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->helperText('Paddle Billing Preis-ID für dieses Add-on')
|
||||
->maxLength(191),
|
||||
TextInput::make('sort')
|
||||
->label('Sortierung')
|
||||
->numeric()
|
||||
->default(0),
|
||||
Toggle::make('active')
|
||||
->label('Aktiv')
|
||||
->default(true),
|
||||
]),
|
||||
Section::make('Limits-Inkremente')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextInput::make('extra_photos')
|
||||
->label('Extra Fotos')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
TextInput::make('extra_guests')
|
||||
->label('Extra Gäste')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
TextInput::make('extra_gallery_days')
|
||||
->label('Galerie +Tage')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('label')
|
||||
->label('Label')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('key')
|
||||
->label('Schlüssel')
|
||||
->copyable()
|
||||
->sortable(),
|
||||
TextColumn::make('price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->toggleable()
|
||||
->copyable(),
|
||||
TextColumn::make('extra_photos')->label('Fotos +'),
|
||||
TextColumn::make('extra_guests')->label('Gäste +'),
|
||||
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
|
||||
BadgeColumn::make('active')
|
||||
->label('Status')
|
||||
->colors([
|
||||
'success' => true,
|
||||
'danger' => false,
|
||||
])
|
||||
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
|
||||
TextColumn::make('sort')
|
||||
->label('Sort')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('active')
|
||||
->label('Aktiv'),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('syncPaddle')
|
||||
->label('Mit Paddle synchronisieren')
|
||||
->icon('heroicon-o-cloud-arrow-up')
|
||||
->action(function (PackageAddon $record) {
|
||||
SyncPackageAddonToPaddle::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Sync gestartet')
|
||||
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\BulkActionGroup::make([
|
||||
Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListPackageAddons::route('/'),
|
||||
'create' => Pages\CreatePackageAddon::route('/create'),
|
||||
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PackageAddonResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PackageAddonResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePackageAddon extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PackageAddonResource::class;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PackageAddonResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PackageAddonResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPackageAddon extends EditRecord
|
||||
{
|
||||
protected static string $resource = PackageAddonResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PackageAddonResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PackageAddonResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPackageAddons extends ListRecords
|
||||
{
|
||||
protected static string $resource = PackageAddonResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Addons\EventAddonCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class EventAddonCatalogController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EventAddonCatalog $catalog) {}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$addons = collect($this->catalog->all())
|
||||
->filter(fn (array $addon) => ! empty($addon['price_id']))
|
||||
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return response()->json(['data' => $addons]);
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/Api/Tenant/EventAddonController.php
Normal file
114
app/Http/Controllers/Api/Tenant/EventAddonController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
|
||||
use App\Http\Requests\Tenant\EventAddonRequest;
|
||||
use App\Http\Resources\Tenant\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Services\Addons\EventAddonCheckoutService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class EventAddonController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EventAddonCheckoutService $checkoutService) {}
|
||||
|
||||
public function checkout(EventAddonCheckoutRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
__('Das Event konnte nicht gefunden werden.'),
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$eventPackage = $event->eventPackage ?: $event->eventPackages()
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
|
||||
if ($eventPackage) {
|
||||
$event->setRelation('eventPackage', $eventPackage);
|
||||
}
|
||||
|
||||
$checkout = $this->checkoutService->createCheckout(
|
||||
$request->attributes->get('tenant'),
|
||||
$event,
|
||||
$request->validated(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'checkout_id' => $checkout['id'] ?? null,
|
||||
'expires_at' => $checkout['expires_at'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function apply(EventAddonRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return ApiError::response(
|
||||
'event_not_found',
|
||||
'Event not accessible',
|
||||
__('Das Event konnte nicht gefunden werden.'),
|
||||
404,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$eventPackage = $event->eventPackage;
|
||||
|
||||
if (! $eventPackage && method_exists($event, 'eventPackages')) {
|
||||
$eventPackage = $event->eventPackages()
|
||||
->with('package')
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $eventPackage) {
|
||||
return ApiError::response(
|
||||
'event_package_missing',
|
||||
'Event package missing',
|
||||
__('Kein Paket ist diesem Event zugeordnet.'),
|
||||
409,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
$eventPackage->fill([
|
||||
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($data['extra_photos'] ?? 0),
|
||||
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($data['extra_guests'] ?? 0),
|
||||
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($data['extend_gallery_days'] ?? 0),
|
||||
]);
|
||||
|
||||
if (isset($data['extend_gallery_days'])) {
|
||||
$base = $eventPackage->gallery_expires_at ?? Carbon::now();
|
||||
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) $data['extend_gallery_days']);
|
||||
}
|
||||
|
||||
$eventPackage->save();
|
||||
|
||||
$event->load([
|
||||
'eventPackage.package',
|
||||
'eventPackages.package',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Add-ons applied successfully.'),
|
||||
'data' => new EventResource($event),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,7 @@ class EventController extends Controller
|
||||
});
|
||||
|
||||
$tenant->refresh();
|
||||
$event->load(['eventType', 'tenant', 'eventPackages.package']);
|
||||
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event created successfully',
|
||||
@@ -222,7 +222,7 @@ class EventController extends Controller
|
||||
'tasks',
|
||||
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'),
|
||||
'eventPackages' => fn ($query) => $query
|
||||
->with('package')
|
||||
->with(['package', 'addons'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at'),
|
||||
]);
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TenantBillingController extends Controller
|
||||
@@ -60,4 +62,58 @@ class TenantBillingController extends Controller
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function addons(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Tenant not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$paginator = EventPackageAddon::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->with(['event:id,name,slug'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
|
||||
return [
|
||||
'id' => $addon->id,
|
||||
'addon_key' => $addon->addon_key,
|
||||
'label' => $addon->metadata['label'] ?? null,
|
||||
'quantity' => (int) ($addon->quantity ?? 1),
|
||||
'status' => $addon->status,
|
||||
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
||||
'currency' => $addon->currency,
|
||||
'extra_photos' => (int) $addon->extra_photos,
|
||||
'extra_guests' => (int) $addon->extra_guests,
|
||||
'extra_gallery_days' => (int) $addon->extra_gallery_days,
|
||||
'purchased_at' => $addon->purchased_at?->toIso8601String(),
|
||||
'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'),
|
||||
'event' => $addon->event ? [
|
||||
'id' => $addon->event->id,
|
||||
'slug' => $addon->event->slug,
|
||||
'name' => $addon->event->name,
|
||||
] : null,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Addons\EventAddonWebhookService;
|
||||
use App\Services\Checkout\CheckoutWebhookService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -10,7 +11,10 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaddleWebhookController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CheckoutWebhookService $webhooks) {}
|
||||
public function __construct(
|
||||
private readonly CheckoutWebhookService $webhooks,
|
||||
private readonly EventAddonWebhookService $addonWebhooks,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
@@ -31,6 +35,7 @@ class PaddleWebhookController extends Controller
|
||||
|
||||
if ($eventType) {
|
||||
$handled = $this->webhooks->handlePaddleEvent($payload);
|
||||
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
||||
}
|
||||
|
||||
Log::info('Paddle webhook processed', [
|
||||
|
||||
32
app/Http/Requests/Tenant/EventAddonCheckoutRequest.php
Normal file
32
app/Http/Requests/Tenant/EventAddonCheckoutRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use App\Services\Addons\EventAddonCatalog;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class EventAddonCheckoutRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var EventAddonCatalog $catalog */
|
||||
$catalog = app(EventAddonCatalog::class);
|
||||
$keys = array_keys($catalog->all());
|
||||
$inRule = $keys === [] ? 'string' : 'in:'.implode(',', $keys);
|
||||
|
||||
return [
|
||||
'addon_key' => ['required', 'string', $inRule],
|
||||
'quantity' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'cancel_url' => ['nullable', 'url'],
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Http/Requests/Tenant/EventAddonRequest.php
Normal file
40
app/Http/Requests/Tenant/EventAddonRequest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventAddonRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'extra_photos' => ['nullable', 'integer', 'min:1'],
|
||||
'extra_guests' => ['nullable', 'integer', 'min:1'],
|
||||
'extend_gallery_days' => ['nullable', 'integer', 'min:1'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function passedValidation(): void
|
||||
{
|
||||
if (
|
||||
$this->input('extra_photos') === null
|
||||
&& $this->input('extra_guests') === null
|
||||
&& $this->input('extend_gallery_days') === null
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'addons' => __('Please provide at least one add-on to apply.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Services\Packages\PackageLimitEvaluator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
@@ -76,6 +77,34 @@ class EventResource extends JsonResource
|
||||
'limits' => $eventPackage && $limitEvaluator
|
||||
? $limitEvaluator->summarizeEventPackage($eventPackage)
|
||||
: null,
|
||||
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
||||
];
|
||||
}
|
||||
|
||||
protected function formatAddons(?\App\Models\EventPackage $eventPackage): array
|
||||
{
|
||||
if (! $eventPackage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$addons = $eventPackage->relationLoaded('addons')
|
||||
? $eventPackage->addons
|
||||
: $eventPackage->addons()->latest()->take(10)->get();
|
||||
|
||||
return $addons->map(function ($addon) {
|
||||
return [
|
||||
'id' => $addon->id,
|
||||
'key' => $addon->addon_key,
|
||||
'label' => $addon->metadata['label'] ?? null,
|
||||
'status' => $addon->status,
|
||||
'price_id' => $addon->price_id,
|
||||
'transaction_id' => $addon->transaction_id,
|
||||
'extra_photos' => (int) $addon->extra_photos,
|
||||
'extra_guests' => (int) $addon->extra_guests,
|
||||
'extra_gallery_days' => (int) $addon->extra_gallery_days,
|
||||
'purchased_at' => $addon->purchased_at?->toIso8601String(),
|
||||
'metadata' => Arr::only($addon->metadata ?? [], ['price_eur']),
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
|
||||
167
app/Jobs/SyncPackageAddonToPaddle.php
Normal file
167
app/Jobs/SyncPackageAddonToPaddle.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleAddonCatalogService;
|
||||
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 SyncPackageAddonToPaddle 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 $addonId, private readonly array $options = []) {}
|
||||
|
||||
public function handle(PaddleAddonCatalogService $catalog): void
|
||||
{
|
||||
$addon = PackageAddon::query()->find($this->addonId);
|
||||
|
||||
if (! $addon) {
|
||||
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, $addon, $productOverrides, $priceOverrides);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark syncing (metadata)
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'paddle_sync_status' => 'syncing',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
]),
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
||||
|
||||
$productResponse = $addon->metadata['paddle_product_id'] ?? null
|
||||
? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product'])
|
||||
: $catalog->createProduct($addon, $payloadOverrides['product']);
|
||||
|
||||
$productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null);
|
||||
|
||||
if (! $productId) {
|
||||
throw new PaddleException('Paddle product ID missing after addon sync.');
|
||||
}
|
||||
|
||||
$priceResponse = $addon->price_id
|
||||
? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
|
||||
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
|
||||
|
||||
$priceId = (string) ($priceResponse['id'] ?? $addon->price_id);
|
||||
|
||||
if (! $priceId) {
|
||||
throw new PaddleException('Paddle price ID missing after addon sync.');
|
||||
}
|
||||
|
||||
$addon->forceFill([
|
||||
'price_id' => $priceId,
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'paddle_sync_status' => 'synced',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_product_id' => $productId,
|
||||
'paddle_snapshot' => [
|
||||
'product' => $productResponse,
|
||||
'price' => $priceResponse,
|
||||
'payload' => $payloadOverrides,
|
||||
],
|
||||
]),
|
||||
])->save();
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Paddle addon sync failed', [
|
||||
'addon_id' => $addon->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'paddle_sync_status' => 'failed',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_error' => [
|
||||
'message' => $exception->getMessage(),
|
||||
'class' => $exception::class,
|
||||
],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $productOverrides
|
||||
* @param array<string, mixed> $priceOverrides
|
||||
* @return array{product: array<string, mixed>, price: array<string, mixed>}
|
||||
*/
|
||||
protected function buildPayloadOverrides(PackageAddon $addon, array $productOverrides, array $priceOverrides): array
|
||||
{
|
||||
// Reuse Package model payload builder shape via duck typing
|
||||
$baseProduct = [
|
||||
'name' => $addon->label,
|
||||
'description' => sprintf('Addon %s', $addon->key),
|
||||
'type' => 'standard',
|
||||
'custom_data' => [
|
||||
'addon_key' => $addon->key,
|
||||
'increments' => $addon->increments,
|
||||
],
|
||||
];
|
||||
|
||||
$basePrice = [
|
||||
'description' => $addon->label,
|
||||
'custom_data' => [
|
||||
'addon_key' => $addon->key,
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'product' => array_merge($baseProduct, $productOverrides),
|
||||
'price' => array_merge($basePrice, $priceOverrides),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $productOverrides
|
||||
* @param array<string, mixed> $priceOverrides
|
||||
*/
|
||||
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void
|
||||
{
|
||||
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
||||
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'paddle_sync_status' => 'dry-run',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_snapshot' => [
|
||||
'dry_run' => true,
|
||||
'payload' => $payloadOverrides,
|
||||
],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
Log::info('Paddle addon dry-run snapshot generated', [
|
||||
'addon_id' => $addon->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class EventPackage extends Model
|
||||
{
|
||||
@@ -20,6 +21,10 @@ class EventPackage extends Model
|
||||
'used_photos',
|
||||
'used_guests',
|
||||
'gallery_expires_at',
|
||||
'limits_snapshot',
|
||||
'extra_photos',
|
||||
'extra_guests',
|
||||
'extra_gallery_days',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -30,6 +35,10 @@ class EventPackage extends Model
|
||||
'gallery_expired_notified_at' => 'datetime',
|
||||
'used_photos' => 'integer',
|
||||
'used_guests' => 'integer',
|
||||
'extra_photos' => 'integer',
|
||||
'extra_guests' => 'integer',
|
||||
'extra_gallery_days' => 'integer',
|
||||
'limits_snapshot' => 'array',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
@@ -42,6 +51,11 @@ class EventPackage extends Model
|
||||
return $this->belongsTo(Package::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function addons(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventPackageAddon::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->gallery_expires_at && $this->gallery_expires_at->isFuture();
|
||||
@@ -53,7 +67,11 @@ class EventPackage extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPhotos = $this->package->max_photos ?? 0;
|
||||
$maxPhotos = $this->effectiveLimits()['max_photos'];
|
||||
|
||||
if ($maxPhotos === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->used_photos < $maxPhotos;
|
||||
}
|
||||
@@ -64,23 +82,84 @@ class EventPackage extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxGuests = $this->package->max_guests ?? 0;
|
||||
$maxGuests = $this->effectiveLimits()['max_guests'];
|
||||
|
||||
if ($maxGuests === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->used_guests < $maxGuests;
|
||||
}
|
||||
|
||||
public function getRemainingPhotosAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_photos ?? 0;
|
||||
$limit = $this->effectiveLimits()['max_photos'] ?? 0;
|
||||
|
||||
return max(0, $max - $this->used_photos);
|
||||
return max(0, (int) $limit - $this->used_photos);
|
||||
}
|
||||
|
||||
public function getRemainingGuestsAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_guests ?? 0;
|
||||
$limit = $this->effectiveLimits()['max_guests'] ?? 0;
|
||||
|
||||
return max(0, $max - $this->used_guests);
|
||||
return max(0, (int) $limit - $this->used_guests);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{max_photos: ?int, max_guests: ?int, gallery_days: ?int, max_tasks: ?int, max_events_per_year: ?int}
|
||||
*/
|
||||
public function effectiveLimits(): array
|
||||
{
|
||||
$snapshot = is_array($this->limits_snapshot) ? $this->limits_snapshot : [];
|
||||
|
||||
$base = [
|
||||
'max_photos' => array_key_exists('max_photos', $snapshot)
|
||||
? $snapshot['max_photos']
|
||||
: ($this->package->max_photos ?? null),
|
||||
'max_guests' => array_key_exists('max_guests', $snapshot)
|
||||
? $snapshot['max_guests']
|
||||
: ($this->package->max_guests ?? null),
|
||||
'gallery_days' => array_key_exists('gallery_days', $snapshot)
|
||||
? $snapshot['gallery_days']
|
||||
: ($this->package->gallery_days ?? null),
|
||||
'max_tasks' => array_key_exists('max_tasks', $snapshot)
|
||||
? $snapshot['max_tasks']
|
||||
: ($this->package->max_tasks ?? null),
|
||||
'max_events_per_year' => array_key_exists('max_events_per_year', $snapshot)
|
||||
? $snapshot['max_events_per_year']
|
||||
: ($this->package->max_events_per_year ?? null),
|
||||
];
|
||||
|
||||
$applyExtra = static function (?int $limit, int $extra): ?int {
|
||||
if ($limit === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$safeExtra = max(0, $extra);
|
||||
|
||||
return max(0, $limit + $safeExtra);
|
||||
};
|
||||
|
||||
$maxPhotos = $applyExtra($this->normalizeLimit($base['max_photos']), (int) ($this->extra_photos ?? 0));
|
||||
$maxGuests = $applyExtra($this->normalizeLimit($base['max_guests']), (int) ($this->extra_guests ?? 0));
|
||||
|
||||
return [
|
||||
'max_photos' => $maxPhotos,
|
||||
'max_guests' => $maxGuests,
|
||||
'gallery_days' => $this->normalizeLimit($base['gallery_days']),
|
||||
'max_tasks' => $this->normalizeLimit($base['max_tasks']),
|
||||
'max_events_per_year' => $this->normalizeLimit($base['max_events_per_year']),
|
||||
];
|
||||
}
|
||||
|
||||
public function effectivePhotoLimit(): ?int
|
||||
{
|
||||
return $this->effectiveLimits()['max_photos'];
|
||||
}
|
||||
|
||||
public function effectiveGuestLimit(): ?int
|
||||
{
|
||||
return $this->effectiveLimits()['max_guests'];
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
@@ -95,6 +174,31 @@ class EventPackage extends Model
|
||||
$days = $eventPackage->package->gallery_days ?? 30;
|
||||
$eventPackage->gallery_expires_at = now()->addDays($days);
|
||||
}
|
||||
|
||||
if (! $eventPackage->limits_snapshot) {
|
||||
$package = $eventPackage->relationLoaded('package')
|
||||
? $eventPackage->package
|
||||
: Package::query()->find($eventPackage->package_id);
|
||||
|
||||
if ($package) {
|
||||
$eventPackage->limits_snapshot = 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function normalizeLimit($value): ?int
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
66
app/Models/EventPackageAddon.php
Normal file
66
app/Models/EventPackageAddon.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
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\BelongsTo;
|
||||
|
||||
class EventPackageAddon extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'event_package_id',
|
||||
'event_id',
|
||||
'tenant_id',
|
||||
'addon_key',
|
||||
'quantity',
|
||||
'extra_photos',
|
||||
'extra_guests',
|
||||
'extra_gallery_days',
|
||||
'price_id',
|
||||
'checkout_id',
|
||||
'transaction_id',
|
||||
'status',
|
||||
'amount',
|
||||
'currency',
|
||||
'metadata',
|
||||
'receipt_payload',
|
||||
'error',
|
||||
'purchased_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'receipt_payload' => 'array',
|
||||
'purchased_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected function increments(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => [
|
||||
'extra_photos' => (int) ($this->extra_photos ?? 0),
|
||||
'extra_guests' => (int) ($this->extra_guests ?? 0),
|
||||
'extra_gallery_days' => (int) ($this->extra_gallery_days ?? 0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function eventPackage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EventPackage::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
44
app/Models/PackageAddon.php
Normal file
44
app/Models/PackageAddon.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PackageAddon extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'label',
|
||||
'price_id',
|
||||
'extra_photos',
|
||||
'extra_guests',
|
||||
'extra_gallery_days',
|
||||
'active',
|
||||
'sort',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
'extra_photos' => 'integer',
|
||||
'extra_guests' => 'integer',
|
||||
'extra_gallery_days' => 'integer',
|
||||
'sort' => 'integer',
|
||||
];
|
||||
|
||||
protected function increments(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => [
|
||||
'extra_photos' => (int) ($this->extra_photos ?? 0),
|
||||
'extra_guests' => (int) ($this->extra_guests ?? 0),
|
||||
'extra_gallery_days' => (int) ($this->extra_gallery_days ?? 0),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
47
app/Notifications/Addons/AddonPurchaseReceipt.php
Normal file
47
app/Notifications/Addons/AddonPurchaseReceipt.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Addons;
|
||||
|
||||
use App\Models\EventPackageAddon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class AddonPurchaseReceipt extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly EventPackageAddon $addon) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$event = $this->addon->event;
|
||||
$tenant = $event?->tenant;
|
||||
$label = $this->addon->metadata['label'] ?? $this->addon->addon_key;
|
||||
$amount = $this->addon->amount ? number_format((float) $this->addon->amount, 2) : null;
|
||||
$currency = $this->addon->currency ?? 'EUR';
|
||||
$url = url('/tenant/events/'.($event?->slug ?? ''));
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('emails.addons.receipt.subject', ['addon' => $label]))
|
||||
->greeting(__('emails.addons.receipt.greeting', ['name' => $tenant?->name ?? __('emails.package_limits.team_fallback')]))
|
||||
->line(__('emails.addons.receipt.body', [
|
||||
'addon' => $label,
|
||||
'event' => $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'),
|
||||
'amount' => $amount ? $amount.' '.$currency : __('emails.addons.receipt.unknown_amount'),
|
||||
]))
|
||||
->line(__('emails.addons.receipt.summary', [
|
||||
'photos' => $this->addon->extra_photos,
|
||||
'guests' => $this->addon->extra_guests,
|
||||
'days' => $this->addon->extra_gallery_days,
|
||||
]))
|
||||
->action(__('emails.addons.receipt.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ class EventPackageGuestLimitNotification extends Notification implements ShouldQ
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'limit' => $this->limit,
|
||||
]))
|
||||
->line(__('emails.package_limits.guest_limit.cta_addon'))
|
||||
->action(__('emails.package_limits.guest_limit.addon_action'), $url)
|
||||
->action(__('emails.package_limits.guest_limit.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ class EventPackagePhotoLimitNotification extends Notification implements ShouldQ
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'limit' => $this->limit,
|
||||
]))
|
||||
->line(__('emails.package_limits.photo_limit.cta_addon'))
|
||||
->action(__('emails.package_limits.photo_limit.addon_action'), $url)
|
||||
->action(__('emails.package_limits.photo_limit.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
|
||||
63
app/Services/Addons/EventAddonCatalog.php
Normal file
63
app/Services/Addons/EventAddonCatalog.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EventAddonCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$dbAddons = PackageAddon::query()
|
||||
->where('active', true)
|
||||
->orderBy('sort')
|
||||
->get()
|
||||
->mapWithKeys(function (PackageAddon $addon) {
|
||||
return [$addon->key => [
|
||||
'label' => $addon->label,
|
||||
'price_id' => $addon->price_id,
|
||||
'increments' => $addon->increments,
|
||||
]];
|
||||
})
|
||||
->all();
|
||||
|
||||
// Fallback to config and merge (DB wins)
|
||||
$configAddons = config('package-addons', []);
|
||||
|
||||
return array_merge($configAddons, $dbAddons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function find(string $key): ?array
|
||||
{
|
||||
return $this->all()[$key] ?? null;
|
||||
}
|
||||
|
||||
public function resolvePriceId(string $key): ?string
|
||||
{
|
||||
$addon = $this->find($key);
|
||||
|
||||
return $addon['price_id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function resolveIncrements(string $key): array
|
||||
{
|
||||
$addon = $this->find($key) ?? [];
|
||||
|
||||
$increments = Arr::get($addon, 'increments', []);
|
||||
|
||||
return collect($increments)
|
||||
->map(fn ($value) => is_numeric($value) ? (int) $value : 0)
|
||||
->filter(fn ($value) => $value > 0)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
112
app/Services/Addons/EventAddonCheckoutService.php
Normal file
112
app/Services/Addons/EventAddonCheckoutService.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventAddonCheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventAddonCatalog $catalog,
|
||||
private readonly PaddleClient $paddle,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
|
||||
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
|
||||
*/
|
||||
public function createCheckout(Tenant $tenant, Event $event, array $payload): array
|
||||
{
|
||||
$addonKey = $payload['addon_key'] ?? null;
|
||||
$quantity = max(1, (int) ($payload['quantity'] ?? 1));
|
||||
|
||||
if (! $addonKey || ! $this->catalog->find($addonKey)) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Unbekanntes Add-on.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$priceId = $this->catalog->resolvePriceId($addonKey);
|
||||
|
||||
if (! $priceId) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Für dieses Add-on ist kein Paddle-Preis hinterlegt.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$event->loadMissing('eventPackage');
|
||||
|
||||
if (! $event->eventPackage) {
|
||||
throw ValidationException::withMessages([
|
||||
'event' => __('Kein Paket für dieses Event hinterlegt.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$addonIntent = (string) Str::uuid();
|
||||
|
||||
$increments = $this->catalog->resolveIncrements($addonKey);
|
||||
|
||||
$metadata = [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'event_id' => (string) $event->id,
|
||||
'event_package_id' => (string) $event->eventPackage->id,
|
||||
'addon_key' => $addonKey,
|
||||
'addon_intent' => $addonIntent,
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
|
||||
$requestPayload = array_filter([
|
||||
'customer_id' => $tenant->paddle_customer_id,
|
||||
'items' => [
|
||||
[
|
||||
'price_id' => $priceId,
|
||||
'quantity' => $quantity,
|
||||
],
|
||||
],
|
||||
'metadata' => $metadata,
|
||||
'success_url' => $payload['success_url'] ?? null,
|
||||
'cancel_url' => $payload['cancel_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$response = $this->paddle->post('/checkout/links', $requestPayload);
|
||||
|
||||
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url');
|
||||
$checkoutId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
|
||||
|
||||
if (! $checkoutUrl) {
|
||||
Log::warning('Paddle addon checkout response missing url', ['response' => $response]);
|
||||
}
|
||||
|
||||
EventPackageAddon::create([
|
||||
'event_package_id' => $event->eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'addon_key' => $addonKey,
|
||||
'quantity' => $quantity,
|
||||
'price_id' => $priceId,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => null,
|
||||
'status' => 'pending',
|
||||
'metadata' => array_merge($metadata, [
|
||||
'increments' => $increments,
|
||||
'provider_payload' => $response,
|
||||
]),
|
||||
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
|
||||
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
|
||||
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
|
||||
]);
|
||||
|
||||
return [
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
|
||||
'id' => $checkoutId,
|
||||
];
|
||||
}
|
||||
}
|
||||
125
app/Services/Addons/EventAddonWebhookService.php
Normal file
125
app/Services/Addons/EventAddonWebhookService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Notifications\Addons\AddonPurchaseReceipt;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class EventAddonWebhookService
|
||||
{
|
||||
public function __construct(private readonly EventAddonCatalog $catalog) {}
|
||||
|
||||
public function handle(array $payload): bool
|
||||
{
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
if ($eventType !== 'transaction.completed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $this->extractMetadata($data);
|
||||
$intentId = $metadata['addon_intent'] ?? null;
|
||||
$addonKey = $metadata['addon_key'] ?? null;
|
||||
|
||||
if (! $intentId || ! $addonKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
|
||||
$checkoutId = $data['checkout_id'] ?? null;
|
||||
|
||||
$addon = EventPackageAddon::query()
|
||||
->where('addon_key', $addonKey)
|
||||
->where(function ($query) use ($intentId, $checkoutId, $transactionId) {
|
||||
$query->where('metadata->addon_intent', $intentId)
|
||||
->orWhere('checkout_id', $checkoutId)
|
||||
->orWhere('transaction_id', $transactionId);
|
||||
})
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $addon) {
|
||||
Log::info('[AddonWebhook] Add-on intent not found', [
|
||||
'intent' => $intentId,
|
||||
'addon_key' => $addonKey,
|
||||
'transaction_id' => $transactionId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($addon->status === 'completed') {
|
||||
return true; // idempotent
|
||||
}
|
||||
|
||||
$increments = $this->catalog->resolveIncrements($addonKey);
|
||||
|
||||
DB::transaction(function () use ($addon, $transactionId, $checkoutId, $data, $increments) {
|
||||
$addon->forceFill([
|
||||
'transaction_id' => $transactionId,
|
||||
'checkout_id' => $addon->checkout_id ?: $checkoutId,
|
||||
'status' => 'completed',
|
||||
'amount' => Arr::get($data, 'totals.grand_total') ?? Arr::get($data, 'amount'),
|
||||
'currency' => Arr::get($data, 'currency_code') ?? Arr::get($data, 'currency'),
|
||||
'metadata' => array_merge($addon->metadata ?? [], ['webhook_payload' => $data]),
|
||||
'receipt_payload' => Arr::get($data, 'receipt_url') ? ['receipt_url' => Arr::get($data, 'receipt_url')] : null,
|
||||
'purchased_at' => now(),
|
||||
])->save();
|
||||
|
||||
/** @var EventPackage $eventPackage */
|
||||
$eventPackage = EventPackage::query()
|
||||
->lockForUpdate()
|
||||
->find($addon->event_package_id);
|
||||
|
||||
if (! $eventPackage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$eventPackage->forceFill([
|
||||
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($increments['extra_photos'] ?? 0) * $addon->quantity,
|
||||
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($increments['extra_guests'] ?? 0) * $addon->quantity,
|
||||
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity,
|
||||
]);
|
||||
|
||||
if (($increments['extra_gallery_days'] ?? 0) > 0) {
|
||||
$base = $eventPackage->gallery_expires_at ?? now();
|
||||
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity);
|
||||
}
|
||||
|
||||
$eventPackage->save();
|
||||
|
||||
$tenant = $addon->tenant;
|
||||
if ($tenant) {
|
||||
Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email])
|
||||
->notify(new AddonPurchaseReceipt($addon));
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractMetadata(array $data): array
|
||||
{
|
||||
$metadata = [];
|
||||
|
||||
if (isset($data['metadata']) && is_array($data['metadata'])) {
|
||||
$metadata = $data['metadata'];
|
||||
}
|
||||
|
||||
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
|
||||
$metadata = array_merge($metadata, $data['custom_data']);
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class PackageLimitEvaluator
|
||||
];
|
||||
}
|
||||
|
||||
$maxPhotos = $eventPackage->package->max_photos;
|
||||
$maxPhotos = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
if ($maxPhotos === null) {
|
||||
return null;
|
||||
@@ -115,17 +115,17 @@ class PackageLimitEvaluator
|
||||
|
||||
public function summarizeEventPackage(EventPackage $eventPackage): array
|
||||
{
|
||||
$package = $eventPackage->package;
|
||||
$limits = $eventPackage->effectiveLimits();
|
||||
|
||||
$photoSummary = $this->buildUsageSummary(
|
||||
(int) $eventPackage->used_photos,
|
||||
$package?->max_photos,
|
||||
$limits['max_photos'],
|
||||
config('package-limits.photo_thresholds', [])
|
||||
);
|
||||
|
||||
$guestSummary = $this->buildUsageSummary(
|
||||
(int) $eventPackage->used_guests,
|
||||
$package?->max_guests,
|
||||
$limits['max_guests'],
|
||||
config('package-limits.guest_thresholds', [])
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class PackageUsageTracker
|
||||
|
||||
public function recordPhotoUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $eventPackage->package?->max_photos;
|
||||
$limit = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
@@ -51,7 +51,7 @@ class PackageUsageTracker
|
||||
|
||||
public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $eventPackage->package?->max_guests;
|
||||
$limit = $eventPackage->effectiveGuestLimit();
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
|
||||
137
app/Services/Paddle/PaddleAddonCatalogService.php
Normal file
137
app/Services/Paddle/PaddleAddonCatalogService.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PaddleAddonCatalogService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createProduct(PackageAddon $addon, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildProductPayload($addon, $overrides);
|
||||
|
||||
return $this->extractEntity($this->client->post('/products', $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateProduct(string $productId, PackageAddon $addon, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildProductPayload($addon, $overrides);
|
||||
|
||||
return $this->extractEntity($this->client->patch("/products/{$productId}", $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createPrice(PackageAddon $addon, string $productId, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildPricePayload($addon, $productId, $overrides);
|
||||
|
||||
return $this->extractEntity($this->client->post('/prices', $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updatePrice(string $priceId, PackageAddon $addon, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildPricePayload($addon, $overrides['product_id'] ?? '', $overrides);
|
||||
|
||||
return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildProductPayload(PackageAddon $addon, array $overrides = []): array
|
||||
{
|
||||
$payload = array_merge([
|
||||
'name' => $overrides['name'] ?? $addon->label,
|
||||
'description' => $overrides['description'] ?? sprintf('Fotospiel Add-on: %s', $addon->label),
|
||||
'tax_category' => $overrides['tax_category'] ?? 'standard',
|
||||
'type' => $overrides['type'] ?? 'standard',
|
||||
'custom_data' => array_merge([
|
||||
'addon_key' => $addon->key,
|
||||
'increments' => $addon->increments,
|
||||
], $overrides['custom_data'] ?? []),
|
||||
], Arr::except($overrides, ['tax_category', 'type', 'custom_data', 'name', 'description']));
|
||||
|
||||
return $this->cleanPayload($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildPricePayload(PackageAddon $addon, string $productId, array $overrides = []): array
|
||||
{
|
||||
$unitPrice = $overrides['unit_price'] ?? $this->defaultUnitPrice($addon);
|
||||
|
||||
$payload = array_merge([
|
||||
'product_id' => $productId,
|
||||
'description' => $overrides['description'] ?? $addon->label,
|
||||
'unit_price' => $unitPrice,
|
||||
'custom_data' => array_merge([
|
||||
'addon_key' => $addon->key,
|
||||
], $overrides['custom_data'] ?? []),
|
||||
], Arr::except($overrides, ['unit_price', 'description', 'custom_data', 'product_id']));
|
||||
|
||||
return $this->cleanPayload($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractEntity(array $payload): array
|
||||
{
|
||||
return Arr::get($payload, 'data', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function defaultUnitPrice(PackageAddon $addon): array
|
||||
{
|
||||
$metaPrice = $addon->metadata['price_eur'] ?? null;
|
||||
|
||||
if (! is_numeric($metaPrice)) {
|
||||
throw new PaddleException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.');
|
||||
}
|
||||
|
||||
$amountCents = (int) round(((float) $metaPrice) * 100);
|
||||
|
||||
return [
|
||||
'amount' => (string) $amountCents,
|
||||
'currency_code' => 'EUR',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -239,11 +239,13 @@ class PhotoboothIngestService
|
||||
return false;
|
||||
}
|
||||
|
||||
$limit = $eventPackage->package->max_photos;
|
||||
$limit = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
return $limit !== null
|
||||
&& $limit > 0
|
||||
&& $eventPackage->used_photos >= $limit;
|
||||
if ($limit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $limit > 0 && $eventPackage->used_photos >= $limit;
|
||||
}
|
||||
|
||||
protected function resolveEmotionId(Event $event): ?int
|
||||
|
||||
@@ -29,7 +29,9 @@ class Mailbox
|
||||
'html' => method_exists($event->message, 'getHtmlBody') ? $event->message->getHtmlBody() : null,
|
||||
'text' => method_exists($event->message, 'getTextBody') ? $event->message->getTextBody() : null,
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
'headers' => (string) $event->message->getHeaders(),
|
||||
'headers' => method_exists($event->message, 'getHeaders') && method_exists($event->message->getHeaders(), 'toString')
|
||||
? $event->message->getHeaders()->toString()
|
||||
: null,
|
||||
];
|
||||
|
||||
self::write($messages);
|
||||
|
||||
Reference in New Issue
Block a user