Ä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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
87
app/Support/SentryReporter.php
Normal file
87
app/Support/SentryReporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user