Fix PayPal billing flow and mobile admin UX
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user