Fix PayPal billing flow and mobile admin UX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-05 10:19:29 +01:00
parent c43327af74
commit 0d7a861875
39 changed files with 1630 additions and 253 deletions

View File

@@ -24,12 +24,6 @@ class CouponPreviewController extends Controller
$package = Package::findOrFail($data['package_id']);
if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.package_not_configured'),
]);
}
$tenant = Auth::user()?->tenant;
try {

View File

@@ -4,18 +4,21 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\EventPackageAddon;
use App\Models\PackagePurchase;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class TenantBillingController extends Controller
{
public function __construct(
private readonly LemonSqueezyOrderService $orders,
private readonly LemonSqueezySubscriptionService $subscriptions,
) {}
@@ -30,45 +33,45 @@ class TenantBillingController extends Controller
], 404);
}
if (! $tenant->lemonsqueezy_customer_id) {
return response()->json([
'data' => [],
'meta' => [
'next' => null,
'previous' => null,
'has_more' => false,
],
]);
}
$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();
$cursor = $request->query('cursor');
$perPage = (int) $request->query('per_page', 25);
$paginator = PackagePurchase::query()
->where('tenant_id', $tenant->id)
->with(['package'])
->orderByDesc('purchased_at')
->orderByDesc('id')
->paginate($perPage, ['*'], 'page', $page);
$query = [
'per_page' => max(1, min($perPage, 100)),
];
$data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) {
$totals = $this->resolvePurchaseTotals($purchase);
$transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey();
if ($cursor) {
$query['after'] = $cursor;
}
try {
$result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query);
} catch (\Throwable $exception) {
Log::warning('Failed to load Lemon Squeezy transactions', [
'tenant_id' => $tenant->id,
'error' => $exception->getMessage(),
]);
return response()->json([
'data' => [],
'message' => 'Failed to load Lemon Squeezy transactions.',
], 502);
}
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' => $result['data'],
'meta' => $result['meta'],
'data' => $data,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
@@ -201,4 +204,184 @@ class TenantBillingController extends Controller
'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',
};
}
}