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)); $locale = $request->user()?->preferred_locale ?? app()->getLocale(); $paginator = PackagePurchase::query() ->where('tenant_id', $tenant->id) ->with(['package']) ->orderByDesc('purchased_at') ->orderByDesc('id') ->paginate($perPage, ['*'], 'page', $page); $data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) { $totals = $this->resolvePurchaseTotals($purchase); $transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey(); return [ 'id' => $purchase->getKey(), 'status' => $purchase->refunded ? 'refunded' : 'completed', 'amount' => $totals['total'], 'currency' => $totals['currency'], 'tax' => $totals['tax'], 'provider' => $purchase->provider ?? 'paypal', 'provider_id' => $transactionId, 'package_name' => $this->resolvePackageName($purchase, $locale), 'purchased_at' => $purchase->purchased_at?->toIso8601String(), 'receipt_url' => route('api.v1.tenant.billing.transactions.receipt', [ 'purchase' => $purchase->getKey(), ], absolute: false), ]; })->values(); return response()->json([ 'data' => $data, 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], ]); } 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(), ], ]); } public function portal(Request $request): JsonResponse { $tenant = $request->attributes->get('tenant'); if (! $tenant) { return response()->json([ 'message' => 'Tenant not found.', ], 404); } $subscriptionId = null; try { $subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id; if (! $subscriptionId) { return response()->json([ 'message' => 'No active subscription found.', ], 404); } Log::debug('Fetching Lemon Squeezy subscription portal URL', [ 'tenant_id' => $tenant->id, 'lemonsqueezy_subscription_id' => $subscriptionId, ]); $subscription = $this->subscriptions->retrieve($subscriptionId); } catch (\Throwable $exception) { $context = [ 'tenant_id' => $tenant->id, 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id, 'lemonsqueezy_subscription_id' => $subscriptionId ?? null, 'error' => $exception->getMessage(), ]; if ($exception instanceof LemonSqueezyException) { $context['lemonsqueezy_status'] = $exception->status(); $context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0'); $context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors'); $context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id'); } Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [ ...$context, ]); return response()->json([ 'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.', ], 502); } $url = $this->subscriptions->portalUrl($subscription) ?? $this->subscriptions->updatePaymentMethodUrl($subscription); if (! $url) { $sessionData = Arr::get($subscription, 'data'); $sessionUrls = Arr::get($subscription, 'attributes.urls'); Log::warning('Lemon Squeezy subscription missing portal URL', [ 'tenant_id' => $tenant->id, 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id, 'lemonsqueezy_subscription_id' => $subscriptionId ?? null, 'subscription_keys' => array_keys($subscription), 'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null, 'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null, ]); return response()->json([ 'message' => 'Lemon Squeezy subscription missing portal URL.', ], 502); } return response()->json([ 'url' => $url, ]); } public function receipt(Request $request, PackagePurchase $purchase): Response { $tenant = $request->attributes->get('tenant'); if (! $tenant || (int) $purchase->tenant_id !== (int) $tenant->id) { abort(404); } $purchase->loadMissing(['tenant.user', 'package']); $locale = $request->user()?->preferred_locale ?? app()->getLocale(); app()->setLocale($locale); $totals = $this->resolvePurchaseTotals($purchase); $currency = $totals['currency']; $total = $totals['total']; $tax = $totals['tax']; $buyer = $purchase->tenant?->user; $buyerName = $buyer?->full_name ?? $buyer?->name ?? $buyer?->email ?? ''; $buyerEmail = $buyer?->email ?? ''; $buyerAddress = $buyer?->address ?? ''; $packageName = $this->resolvePackageName($purchase, $locale); $packageTypeLabel = $this->resolvePackageTypeLabel($purchase->package?->type); $providerLabel = $this->resolveProviderLabel($purchase->provider); $purchaseDate = $this->formatDate($purchase->purchased_at, $locale); $amountFormatted = $this->formatCurrency($total, $currency, $locale); $taxFormatted = $tax !== null ? $this->formatCurrency($tax, $currency, $locale) : null; $totalFormatted = $amountFormatted; $html = view('billing.receipt', [ 'receiptNumber' => (string) $purchase->getKey(), 'purchaseDate' => $purchaseDate, 'packageName' => $packageName, 'packageTypeLabel' => $packageTypeLabel, 'providerLabel' => $providerLabel, 'orderId' => $purchase->provider_id ?? $purchase->getKey(), 'buyerName' => $buyerName, 'buyerEmail' => $buyerEmail, 'buyerAddress' => $buyerAddress, 'amountFormatted' => $amountFormatted, 'taxFormatted' => $taxFormatted, 'totalFormatted' => $totalFormatted, 'currency' => $currency, 'companyName' => config('app.name', 'Fotospiel'), 'companyEmail' => config('mail.from.address', 'info@fotospiel.app'), ])->render(); $options = new Options; $options->set('isHtml5ParserEnabled', true); $options->set('isRemoteEnabled', true); $options->set('defaultFont', 'Helvetica'); $dompdf = new Dompdf($options); $dompdf->setPaper('A4', 'portrait'); $dompdf->loadHtml($html, 'UTF-8'); $dompdf->render(); $pdfBinary = $dompdf->output(); $filenameStem = Str::slug($packageName ?: 'receipt'); return response($pdfBinary) ->header('Content-Type', 'application/pdf') ->header('Content-Disposition', 'inline; filename="receipt-'.$filenameStem.'.pdf"'); } /** * @return array{currency: string, total: float, tax: float|null} */ private function resolvePurchaseTotals(PackagePurchase $purchase): array { $metadata = $purchase->metadata ?? []; $totals = $metadata['paypal_totals'] ?? $metadata['lemonsqueezy_totals'] ?? []; $currency = $totals['currency'] ?? $metadata['currency'] ?? $purchase->package?->currency ?? 'EUR'; $total = array_key_exists('total', $totals) ? (float) $totals['total'] : (float) $purchase->price; $tax = array_key_exists('tax', $totals) ? (float) $totals['tax'] : null; return [ 'currency' => strtoupper((string) $currency), 'total' => round($total, 2), 'tax' => $tax !== null ? round($tax, 2) : null, ]; } private function resolvePackageName(PackagePurchase $purchase, string $locale): string { $package = $purchase->package; if (! $package) { return ''; } $localized = $package->getNameForLocale($locale); return $localized ?: (string) $package->name; } private function resolveProviderLabel(?string $provider): string { $provider = $provider ?: 'paypal'; $labelKey = 'emails.purchase.provider.'.$provider; $label = __($labelKey); if ($label === $labelKey) { return ucfirst($provider); } return $label; } private function resolvePackageTypeLabel(?string $type): string { $type = $type ?: 'endcustomer'; $labelKey = 'emails.purchase.package_type.'.$type; $label = __($labelKey); if ($label === $labelKey) { return ucfirst($type); } return $label; } private function formatCurrency(float $amount, string $currency, string $locale): string { $formatter = class_exists(\NumberFormatter::class) ? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY) : null; if ($formatter) { $formatted = $formatter->formatCurrency($amount, $currency); if ($formatted !== false) { return $formatted; } } $symbol = match (strtoupper($currency)) { 'EUR' => '€', 'USD' => '$', default => strtoupper($currency).' ', }; return $symbol.number_format($amount, 2, ',', '.'); } private function formatDate(?\Carbon\CarbonInterface $date, string $locale): string { if (! $date) { return ''; } $localized = $date->locale($locale); if (str_starts_with($locale, 'en')) { return $localized->translatedFormat('F j, Y'); } return $localized->translatedFormat('d. F Y'); } private function mapLocale(string $locale): string { $normalized = strtolower(str_replace('_', '-', $locale)); return match (true) { str_starts_with($normalized, 'de') => 'de_DE', str_starts_with($normalized, 'en') => 'en_US', default => 'de_DE', }; } }