Änderungen (relevant):

- Add‑on Checkout auf Transactions + Transaction‑ID speichern: app/Services/Addons/EventAddonCheckoutService.php
  - Paket/Marketing Checkout auf Transactions: app/Services/Paddle/PaddleCheckoutService.php
  - Gift‑Voucher Checkout: Customer anlegen/finden + Transactions: app/Services/GiftVouchers/
    GiftVoucherCheckoutService.php
  - Tests aktualisiert: tests/Feature/Tenant/EventAddonCheckoutTest.php, tests/Unit/PaddleCheckoutServiceTest.php,
tests/Unit/GiftVoucherCheckoutServiceTest.php
This commit is contained in:
Codex Agent
2025-12-29 18:04:28 +01:00
parent 795e37ee12
commit 5f521d055f
26 changed files with 783 additions and 102 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Exceptions;
use App\Support\ApiError;
use App\Support\SentryReporter;
use Illuminate\Database\Connectors\ConnectionException as DatabaseConnectionException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
@@ -43,13 +44,9 @@ class Handler extends ExceptionHandler
return;
}
if (! app()->bound('sentry') || empty(config('sentry.dsn'))) {
return;
}
$this->configureSentryScope();
app('sentry')->captureException($e);
SentryReporter::captureException($e);
});
}

View File

@@ -34,6 +34,8 @@ class OnboardingController extends Controller
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'),
'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
'branding_completed' => (bool) ($status['palette'] ?? false),
'tasks_configured' => (bool) ($status['packages'] ?? false),
'event_created' => (bool) ($status['event'] ?? false),
@@ -84,6 +86,10 @@ class OnboardingController extends Controller
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
break;
case 'dismissed':
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
break;
case 'completed':
TenantOnboardingState::markCompleted($tenant, $meta);
break;

View File

@@ -32,8 +32,10 @@ use App\Services\Checkout\CheckoutSessionService;
use App\Services\Security\PhotoSecurityScanner;
use App\Services\Storage\EventStorageManager;
use App\Services\Storage\StorageHealthService;
use App\Support\SentryReporter;
use App\Testing\Mailbox;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Console\Events\ScheduledTaskFailed;
use Illuminate\Http\Request;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Queue\Events\JobFailed;
@@ -241,36 +243,63 @@ class AppServiceProvider extends ServiceProvider
];
});
if (config('storage-monitor.queue_failure_alerts')) {
Queue::failing(function (JobFailed $event) {
$context = [
'queue' => $event->job->getQueue(),
'job' => $event->job->resolveName(),
'exception' => $event->exception->getMessage(),
];
Queue::failing(function (JobFailed $event) {
$context = [
'queue' => $event->job->getQueue(),
'job' => $event->job->resolveName(),
'exception' => $event->exception->getMessage(),
];
$command = data_get($event->job->payload(), 'data.command');
$command = data_get($event->job->payload(), 'data.command');
if (is_string($command)) {
try {
$instance = @unserialize($command, ['allowed_classes' => true]);
if (is_object($instance)) {
foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) {
if (isset($instance->{$property})) {
$context[$label] = $instance->{$property};
}
if (is_string($command)) {
try {
$instance = @unserialize($command, ['allowed_classes' => true]);
if (is_object($instance)) {
foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) {
if (isset($instance->{$property})) {
$context[$label] = $instance->{$property};
}
}
} catch (\Throwable $e) {
$context['unserialize_error'] = $e->getMessage();
}
} catch (\Throwable $e) {
$context['unserialize_error'] = $e->getMessage();
}
}
SentryReporter::captureException($event->exception, [
'tags' => [
'queue' => $context['queue'],
'job' => $context['job'],
'connection' => $event->connectionName,
],
'extra' => [
'event_id' => $context['event_id'] ?? null,
'photo_id' => $context['photo_id'] ?? null,
],
]);
if (config('storage-monitor.queue_failure_alerts')) {
if ($mail = config('storage-monitor.alert_recipients.mail')) {
Notification::route('mail', $mail)->notify(new UploadPipelineFailed($context));
}
});
}
}
});
EventFacade::listen(ScheduledTaskFailed::class, function (ScheduledTaskFailed $event): void {
$task = $event->task;
SentryReporter::captureException($event->exception, [
'tags' => [
'schedule_command' => $task->command ?? 'unknown',
'schedule_expression' => $task->expression ?? null,
],
'extra' => [
'schedule_description' => $task->description ?? null,
'schedule_timezone' => $task->timezone ?? null,
],
]);
});
if ($this->app->runningInConsole()) {
$this->app->register(\App\Providers\Filament\AdminPanelProvider::class);

View File

@@ -5,8 +5,8 @@ namespace App\Services\Addons;
use App\Models\Event;
use App\Models\EventPackageAddon;
use App\Models\Tenant;
use App\Services\Paddle\PaddleCustomerService;
use App\Services\Paddle\PaddleClient;
use App\Services\Paddle\PaddleCustomerService;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -86,15 +86,20 @@ class EventAddonCheckoutService
'quantity' => $quantity,
],
],
'metadata' => $metadata,
'success_url' => $payload['success_url'] ?? null,
'cancel_url' => $payload['cancel_url'] ?? null,
'custom_data' => $metadata,
], static fn ($value) => $value !== null && $value !== '');
$response = $this->paddle->post('/checkout/links', $requestPayload);
$response = $this->paddle->post('/transactions', $requestPayload);
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url');
$checkoutId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
$checkoutUrl = Arr::get($response, 'data.checkout.url')
?? Arr::get($response, 'checkout.url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url');
$checkoutId = Arr::get($response, 'data.checkout_id')
?? Arr::get($response, 'data.checkout.id')
?? Arr::get($response, 'checkout_id')
?? Arr::get($response, 'checkout.id');
$transactionId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $checkoutUrl) {
Log::warning('Paddle addon checkout response missing url', ['response' => $response]);
@@ -108,7 +113,7 @@ class EventAddonCheckoutService
'quantity' => $quantity,
'price_id' => $priceId,
'checkout_id' => $checkoutId,
'transaction_id' => null,
'transaction_id' => $transactionId,
'status' => 'pending',
'metadata' => array_merge($metadata, [
'increments' => $increments,
@@ -126,8 +131,10 @@ class EventAddonCheckoutService
return [
'checkout_url' => $checkoutUrl,
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
'id' => $checkoutId,
'expires_at' => Arr::get($response, 'data.checkout.expires_at')
?? Arr::get($response, 'data.expires_at')
?? Arr::get($response, 'expires_at'),
'id' => $transactionId ?? $checkoutId,
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\GiftVouchers;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleClient;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
@@ -49,6 +50,8 @@ class GiftVoucherCheckoutService
]);
}
$customerId = $this->ensureCustomerId($data['purchaser_email']);
$payload = [
'items' => [
[
@@ -56,7 +59,7 @@ class GiftVoucherCheckoutService
'quantity' => 1,
],
],
'customer_email' => $data['purchaser_email'],
'customer_id' => $customerId,
'custom_data' => array_filter([
'type' => 'gift_voucher',
'tier_key' => $tier['key'],
@@ -66,15 +69,18 @@ class GiftVoucherCheckoutService
'message' => $data['message'] ?? null,
'app_locale' => App::getLocale(),
]),
'success_url' => $data['success_url'] ?? route('marketing.success', ['locale' => App::getLocale(), 'type' => 'gift']),
'cancel_url' => $data['return_url'] ?? route('packages', ['locale' => App::getLocale()]),
];
$response = $this->client->post('/checkout/links', $payload);
$response = $this->client->post('/transactions', $payload);
return [
'checkout_url' => Arr::get($response, 'data.url') ?? Arr::get($response, 'url'),
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
'checkout_url' => Arr::get($response, 'data.checkout.url')
?? Arr::get($response, 'checkout.url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url'),
'expires_at' => Arr::get($response, 'data.checkout.expires_at')
?? Arr::get($response, 'data.expires_at')
?? Arr::get($response, 'expires_at'),
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
];
}
@@ -97,4 +103,43 @@ class GiftVoucherCheckoutService
return $tier;
}
protected function ensureCustomerId(string $email): string
{
$payload = ['email' => $email];
try {
$response = $this->client->post('/customers', $payload);
} catch (PaddleException $exception) {
$customerId = $this->resolveExistingCustomerId($email, $exception);
if ($customerId) {
return $customerId;
}
throw $exception;
}
$customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $customerId) {
throw new PaddleException('Failed to create Paddle customer.');
}
return $customerId;
}
protected function resolveExistingCustomerId(string $email, PaddleException $exception): ?string
{
if ($exception->status() !== 409 || Arr::get($exception->context(), 'error.code') !== 'customer_already_exists') {
return null;
}
$response = $this->client->get('/customers', [
'email' => $email,
'per_page' => 1,
]);
return Arr::get($response, 'data.0.id') ?? Arr::get($response, 'data.0.customer_id');
}
}

View File

@@ -5,7 +5,6 @@ namespace App\Services\Paddle;
use App\Models\Package;
use App\Models\Tenant;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
class PaddleCheckoutService
@@ -22,15 +21,6 @@ class PaddleCheckoutService
{
$customerId = $this->customers->ensureCustomerId($tenant);
$successUrl = $options['success_url'] ?? route('marketing.success', [
'locale' => App::getLocale(),
'packageId' => $package->id,
]);
$returnUrl = $options['return_url'] ?? route('packages', [
'locale' => App::getLocale(),
'highlight' => $package->slug,
]);
$customData = $this->buildMetadata(
$tenant,
$package,
@@ -46,21 +36,18 @@ class PaddleCheckoutService
],
],
'custom_data' => $customData,
'success_url' => $successUrl,
'cancel_url' => $returnUrl,
];
if (! empty($options['discount_id'])) {
$payload['discount_id'] = $options['discount_id'];
}
if ($tenant->contact_email) {
$payload['customer_email'] = $tenant->contact_email;
}
$response = $this->client->post('/transactions', $payload);
$response = $this->client->post('/checkout/links', $payload);
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url');
$checkoutUrl = Arr::get($response, 'data.checkout.url')
?? Arr::get($response, 'checkout.url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url');
if (! $checkoutUrl) {
Log::warning('Paddle checkout response missing url', ['response' => $response]);
@@ -68,7 +55,9 @@ class PaddleCheckoutService
return [
'checkout_url' => $checkoutUrl,
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
'expires_at' => Arr::get($response, 'data.checkout.expires_at')
?? Arr::get($response, 'data.expires_at')
?? Arr::get($response, 'expires_at'),
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
];
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Support;
use Sentry\State\Scope;
use Throwable;
class SentryReporter
{
private static ?\WeakMap $reported = null;
/**
* @param array{tags?: array<string, string|int|null>, extra?: array<string, mixed>} $context
*/
public static function captureException(Throwable $exception, array $context = []): void
{
if (! self::shouldReport()) {
return;
}
if (self::wasReported($exception)) {
return;
}
self::markReported($exception);
if (! function_exists('\Sentry\withScope')) {
app('sentry')->captureException($exception);
return;
}
\Sentry\withScope(function (Scope $scope) use ($exception, $context): void {
self::applyContext($scope, $context);
app('sentry')->captureException($exception);
});
}
private static function shouldReport(): bool
{
return app()->bound('sentry') && ! empty(config('sentry.dsn'));
}
private static function wasReported(Throwable $exception): bool
{
$reported = self::reportedMap();
return $reported->offsetExists($exception);
}
private static function markReported(Throwable $exception): void
{
$reported = self::reportedMap();
$reported[$exception] = true;
}
private static function reportedMap(): \WeakMap
{
if (! self::$reported instanceof \WeakMap) {
self::$reported = new \WeakMap;
}
return self::$reported;
}
/**
* @param array{tags?: array<string, string|int|null>, extra?: array<string, mixed>} $context
*/
private static function applyContext(Scope $scope, array $context): void
{
foreach (($context['tags'] ?? []) as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$scope->setTag((string) $key, (string) $value);
}
foreach (($context['extra'] ?? []) as $key => $value) {
if ($value === null) {
continue;
}
$scope->setExtra((string) $key, $value);
}
}
}