Ä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;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
|
use App\Support\SentryReporter;
|
||||||
use Illuminate\Database\Connectors\ConnectionException as DatabaseConnectionException;
|
use Illuminate\Database\Connectors\ConnectionException as DatabaseConnectionException;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
@@ -43,13 +44,9 @@ class Handler extends ExceptionHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! app()->bound('sentry') || empty(config('sentry.dsn'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->configureSentryScope();
|
$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'),
|
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
|
||||||
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
|
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
|
||||||
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
|
'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),
|
'branding_completed' => (bool) ($status['palette'] ?? false),
|
||||||
'tasks_configured' => (bool) ($status['packages'] ?? false),
|
'tasks_configured' => (bool) ($status['packages'] ?? false),
|
||||||
'event_created' => (bool) ($status['event'] ?? 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());
|
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'dismissed':
|
||||||
|
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
|
||||||
|
break;
|
||||||
|
|
||||||
case 'completed':
|
case 'completed':
|
||||||
TenantOnboardingState::markCompleted($tenant, $meta);
|
TenantOnboardingState::markCompleted($tenant, $meta);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ use App\Services\Checkout\CheckoutSessionService;
|
|||||||
use App\Services\Security\PhotoSecurityScanner;
|
use App\Services\Security\PhotoSecurityScanner;
|
||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
use App\Services\Storage\StorageHealthService;
|
use App\Services\Storage\StorageHealthService;
|
||||||
|
use App\Support\SentryReporter;
|
||||||
use App\Testing\Mailbox;
|
use App\Testing\Mailbox;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Console\Events\ScheduledTaskFailed;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Mail\Events\MessageSent;
|
use Illuminate\Mail\Events\MessageSent;
|
||||||
use Illuminate\Queue\Events\JobFailed;
|
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) {
|
||||||
Queue::failing(function (JobFailed $event) {
|
$context = [
|
||||||
$context = [
|
'queue' => $event->job->getQueue(),
|
||||||
'queue' => $event->job->getQueue(),
|
'job' => $event->job->resolveName(),
|
||||||
'job' => $event->job->resolveName(),
|
'exception' => $event->exception->getMessage(),
|
||||||
'exception' => $event->exception->getMessage(),
|
];
|
||||||
];
|
|
||||||
|
|
||||||
$command = data_get($event->job->payload(), 'data.command');
|
$command = data_get($event->job->payload(), 'data.command');
|
||||||
|
|
||||||
if (is_string($command)) {
|
if (is_string($command)) {
|
||||||
try {
|
try {
|
||||||
$instance = @unserialize($command, ['allowed_classes' => true]);
|
$instance = @unserialize($command, ['allowed_classes' => true]);
|
||||||
if (is_object($instance)) {
|
if (is_object($instance)) {
|
||||||
foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) {
|
foreach (['eventId' => 'event_id', 'photoId' => 'photo_id'] as $property => $label) {
|
||||||
if (isset($instance->{$property})) {
|
if (isset($instance->{$property})) {
|
||||||
$context[$label] = $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')) {
|
if ($mail = config('storage-monitor.alert_recipients.mail')) {
|
||||||
Notification::route('mail', $mail)->notify(new UploadPipelineFailed($context));
|
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()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->app->register(\App\Providers\Filament\AdminPanelProvider::class);
|
$this->app->register(\App\Providers\Filament\AdminPanelProvider::class);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ namespace App\Services\Addons;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventPackageAddon;
|
use App\Models\EventPackageAddon;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Paddle\PaddleCustomerService;
|
|
||||||
use App\Services\Paddle\PaddleClient;
|
use App\Services\Paddle\PaddleClient;
|
||||||
|
use App\Services\Paddle\PaddleCustomerService;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -86,15 +86,20 @@ class EventAddonCheckoutService
|
|||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'metadata' => $metadata,
|
'custom_data' => $metadata,
|
||||||
'success_url' => $payload['success_url'] ?? null,
|
|
||||||
'cancel_url' => $payload['cancel_url'] ?? null,
|
|
||||||
], static fn ($value) => $value !== null && $value !== '');
|
], 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');
|
$checkoutUrl = Arr::get($response, 'data.checkout.url')
|
||||||
$checkoutId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
|
?? 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) {
|
if (! $checkoutUrl) {
|
||||||
Log::warning('Paddle addon checkout response missing url', ['response' => $response]);
|
Log::warning('Paddle addon checkout response missing url', ['response' => $response]);
|
||||||
@@ -108,7 +113,7 @@ class EventAddonCheckoutService
|
|||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'price_id' => $priceId,
|
'price_id' => $priceId,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'transaction_id' => null,
|
'transaction_id' => $transactionId,
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
'metadata' => array_merge($metadata, [
|
'metadata' => array_merge($metadata, [
|
||||||
'increments' => $increments,
|
'increments' => $increments,
|
||||||
@@ -126,8 +131,10 @@ class EventAddonCheckoutService
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'checkout_url' => $checkoutUrl,
|
'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')
|
||||||
'id' => $checkoutId,
|
?? Arr::get($response, 'data.expires_at')
|
||||||
|
?? Arr::get($response, 'expires_at'),
|
||||||
|
'id' => $transactionId ?? $checkoutId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\GiftVouchers;
|
namespace App\Services\GiftVouchers;
|
||||||
|
|
||||||
|
use App\Services\Paddle\Exceptions\PaddleException;
|
||||||
use App\Services\Paddle\PaddleClient;
|
use App\Services\Paddle\PaddleClient;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
@@ -49,6 +50,8 @@ class GiftVoucherCheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$customerId = $this->ensureCustomerId($data['purchaser_email']);
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'items' => [
|
'items' => [
|
||||||
[
|
[
|
||||||
@@ -56,7 +59,7 @@ class GiftVoucherCheckoutService
|
|||||||
'quantity' => 1,
|
'quantity' => 1,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'customer_email' => $data['purchaser_email'],
|
'customer_id' => $customerId,
|
||||||
'custom_data' => array_filter([
|
'custom_data' => array_filter([
|
||||||
'type' => 'gift_voucher',
|
'type' => 'gift_voucher',
|
||||||
'tier_key' => $tier['key'],
|
'tier_key' => $tier['key'],
|
||||||
@@ -66,15 +69,18 @@ class GiftVoucherCheckoutService
|
|||||||
'message' => $data['message'] ?? null,
|
'message' => $data['message'] ?? null,
|
||||||
'app_locale' => App::getLocale(),
|
'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 [
|
return [
|
||||||
'checkout_url' => Arr::get($response, 'data.url') ?? Arr::get($response, 'url'),
|
'checkout_url' => Arr::get($response, 'data.checkout.url')
|
||||||
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
|
?? 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'),
|
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -97,4 +103,43 @@ class GiftVoucherCheckoutService
|
|||||||
|
|
||||||
return $tier;
|
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\Package;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class PaddleCheckoutService
|
class PaddleCheckoutService
|
||||||
@@ -22,15 +21,6 @@ class PaddleCheckoutService
|
|||||||
{
|
{
|
||||||
$customerId = $this->customers->ensureCustomerId($tenant);
|
$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(
|
$customData = $this->buildMetadata(
|
||||||
$tenant,
|
$tenant,
|
||||||
$package,
|
$package,
|
||||||
@@ -46,21 +36,18 @@ class PaddleCheckoutService
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
'custom_data' => $customData,
|
'custom_data' => $customData,
|
||||||
'success_url' => $successUrl,
|
|
||||||
'cancel_url' => $returnUrl,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! empty($options['discount_id'])) {
|
if (! empty($options['discount_id'])) {
|
||||||
$payload['discount_id'] = $options['discount_id'];
|
$payload['discount_id'] = $options['discount_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant->contact_email) {
|
$response = $this->client->post('/transactions', $payload);
|
||||||
$payload['customer_email'] = $tenant->contact_email;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $this->client->post('/checkout/links', $payload);
|
$checkoutUrl = Arr::get($response, 'data.checkout.url')
|
||||||
|
?? Arr::get($response, 'checkout.url')
|
||||||
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url');
|
?? Arr::get($response, 'data.url')
|
||||||
|
?? Arr::get($response, 'url');
|
||||||
|
|
||||||
if (! $checkoutUrl) {
|
if (! $checkoutUrl) {
|
||||||
Log::warning('Paddle checkout response missing url', ['response' => $response]);
|
Log::warning('Paddle checkout response missing url', ['response' => $response]);
|
||||||
@@ -68,7 +55,9 @@ class PaddleCheckoutService
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'checkout_url' => $checkoutUrl,
|
'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'),
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,12 +36,55 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
\App\Console\Commands\SeedDemoSwitcherTenants::class,
|
\App\Console\Commands\SeedDemoSwitcherTenants::class,
|
||||||
])
|
])
|
||||||
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
|
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
|
||||||
$schedule->command('package:check-status')->dailyAt('06:00');
|
$onFailure = static function (string $command): \Closure {
|
||||||
$schedule->command('photobooth:cleanup-expired')->hourly()->withoutOverlapping();
|
return static function () use ($command): void {
|
||||||
$schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping();
|
\App\Support\SentryReporter::captureException(
|
||||||
$schedule->command('exports:purge')->dailyAt('02:00');
|
new \RuntimeException('Scheduled command failed'),
|
||||||
$schedule->command('tenants:retention-scan')->dailyAt('03:00');
|
[
|
||||||
$schedule->command('guest:feedback-reminders')->dailyAt('22:00');
|
'tags' => [
|
||||||
|
'schedule_command' => $command,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$schedule->command('package:check-status')
|
||||||
|
->dailyAt('06:00')
|
||||||
|
->onFailure($onFailure('package:check-status'));
|
||||||
|
$schedule->command('storage:monitor')
|
||||||
|
->everyFiveMinutes()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onFailure($onFailure('storage:monitor'));
|
||||||
|
$schedule->command('storage:check-upload-queues')
|
||||||
|
->everyFiveMinutes()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onFailure($onFailure('storage:check-upload-queues'));
|
||||||
|
$schedule->command('storage:archive-pending')
|
||||||
|
->dailyAt('01:00')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onFailure($onFailure('storage:archive-pending'));
|
||||||
|
$schedule->command('photobooth:cleanup-expired')
|
||||||
|
->hourly()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onFailure($onFailure('photobooth:cleanup-expired'));
|
||||||
|
$schedule->command('photobooth:ingest')
|
||||||
|
->everyFiveMinutes()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onFailure($onFailure('photobooth:ingest'));
|
||||||
|
$schedule->command('checkout:send-reminders')
|
||||||
|
->hourly()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->onFailure($onFailure('checkout:send-reminders'));
|
||||||
|
$schedule->command('exports:purge')
|
||||||
|
->dailyAt('02:00')
|
||||||
|
->onFailure($onFailure('exports:purge'));
|
||||||
|
$schedule->command('tenants:retention-scan')
|
||||||
|
->dailyAt('03:00')
|
||||||
|
->onFailure($onFailure('tenants:retention-scan'));
|
||||||
|
$schedule->command('guest:feedback-reminders')
|
||||||
|
->dailyAt('22:00')
|
||||||
|
->onFailure($onFailure('guest:feedback-reminders'));
|
||||||
})
|
})
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->trustProxies(
|
$middleware->trustProxies(
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ return [
|
|||||||
|
|
||||||
'waits' => [
|
'waits' => [
|
||||||
'redis:default' => 60,
|
'redis:default' => 60,
|
||||||
|
'redis:media-storage' => 60,
|
||||||
|
'redis:media-security' => 60,
|
||||||
|
'redis:notifications' => 60,
|
||||||
|
'redis:webhooks' => 60,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -197,9 +201,22 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'defaults' => [
|
'defaults' => [
|
||||||
'supervisor-1' => [
|
'supervisor-default' => [
|
||||||
'connection' => 'redis',
|
'connection' => 'redis',
|
||||||
'queue' => ['default'],
|
'queue' => ['default', 'notifications', 'webhooks'],
|
||||||
|
'balance' => 'auto',
|
||||||
|
'autoScalingStrategy' => 'time',
|
||||||
|
'maxProcesses' => 1,
|
||||||
|
'maxTime' => 0,
|
||||||
|
'maxJobs' => 0,
|
||||||
|
'memory' => 128,
|
||||||
|
'tries' => 1,
|
||||||
|
'timeout' => 60,
|
||||||
|
'nice' => 0,
|
||||||
|
],
|
||||||
|
'supervisor-media' => [
|
||||||
|
'connection' => 'redis',
|
||||||
|
'queue' => ['media-storage', 'media-security'],
|
||||||
'balance' => 'auto',
|
'balance' => 'auto',
|
||||||
'autoScalingStrategy' => 'time',
|
'autoScalingStrategy' => 'time',
|
||||||
'maxProcesses' => 1,
|
'maxProcesses' => 1,
|
||||||
@@ -214,17 +231,25 @@ return [
|
|||||||
|
|
||||||
'environments' => [
|
'environments' => [
|
||||||
'production' => [
|
'production' => [
|
||||||
'supervisor-1' => [
|
'supervisor-default' => [
|
||||||
'maxProcesses' => 10,
|
'maxProcesses' => 10,
|
||||||
'balanceMaxShift' => 1,
|
'balanceMaxShift' => 1,
|
||||||
'balanceCooldown' => 3,
|
'balanceCooldown' => 3,
|
||||||
],
|
],
|
||||||
|
'supervisor-media' => [
|
||||||
|
'maxProcesses' => 3,
|
||||||
|
'balanceMaxShift' => 1,
|
||||||
|
'balanceCooldown' => 3,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'local' => [
|
'local' => [
|
||||||
'supervisor-1' => [
|
'supervisor-default' => [
|
||||||
'maxProcesses' => 3,
|
'maxProcesses' => 3,
|
||||||
],
|
],
|
||||||
|
'supervisor-media' => [
|
||||||
|
'maxProcesses' => 1,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ services:
|
|||||||
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env
|
- path: .env
|
||||||
command: /var/www/html/scripts/queue-worker.sh default
|
command: /var/www/html/scripts/queue-worker.sh default,notifications,webhooks
|
||||||
environment:
|
environment:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
SKIP_CODE_SYNC: "1"
|
SKIP_CODE_SYNC: "1"
|
||||||
@@ -237,6 +237,30 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
media-security-worker:
|
||||||
|
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
command: /var/www/html/scripts/queue-worker.sh media-security
|
||||||
|
environment:
|
||||||
|
<<: *app-env
|
||||||
|
QUEUE_TRIES: 3
|
||||||
|
QUEUE_SLEEP: 5
|
||||||
|
SKIP_CODE_SYNC: "1"
|
||||||
|
volumes:
|
||||||
|
- app-code:/var/www/html
|
||||||
|
- app-storage:/var/www/html/storage
|
||||||
|
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- photobooth-network
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ export type TenantOnboardingStatus = {
|
|||||||
admin_app_opened_at?: string | null;
|
admin_app_opened_at?: string | null;
|
||||||
primary_event_id?: number | string | null;
|
primary_event_id?: number | string | null;
|
||||||
selected_packages?: unknown;
|
selected_packages?: unknown;
|
||||||
|
dismissed_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
branding_completed?: boolean;
|
branding_completed?: boolean;
|
||||||
tasks_configured?: boolean;
|
tasks_configured?: boolean;
|
||||||
event_created?: boolean;
|
event_created?: boolean;
|
||||||
|
|||||||
@@ -2217,6 +2217,24 @@
|
|||||||
"checkoutMissing": "Checkout konnte nicht gestartet werden.",
|
"checkoutMissing": "Checkout konnte nicht gestartet werden.",
|
||||||
"checkoutFailed": "Add-on Checkout fehlgeschlagen."
|
"checkoutFailed": "Add-on Checkout fehlgeschlagen."
|
||||||
},
|
},
|
||||||
|
"legalConsent": {
|
||||||
|
"title": "Vor dem Kauf",
|
||||||
|
"description": "Bitte bestätige die rechtlichen Hinweise, bevor du ein Add-on kaufst.",
|
||||||
|
"checkboxTerms": "Ich habe die AGB, die Datenschutzerklärung und die Widerrufsbelehrung gelesen und akzeptiere sie.",
|
||||||
|
"checkboxWaiver": "Ich verlange ausdrücklich, dass mit der Bereitstellung der digitalen Leistung (Aktivierung meines Event-Add-ons) vor Ablauf der Widerrufsfrist begonnen wird. Mir ist bekannt, dass ich mein Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.",
|
||||||
|
"errorTerms": "Bitte bestätige AGB, Datenschutzerklärung und Widerrufsbelehrung.",
|
||||||
|
"errorWaiver": "Bitte bestätige den sofortigen Leistungsbeginn und das vorzeitige Erlöschen des Widerrufsrechts.",
|
||||||
|
"confirm": "Weiter zum Checkout",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
|
"eventStartConsent": {
|
||||||
|
"title": "Vor dem ersten Event",
|
||||||
|
"description": "Bitte bestätige den sofortigen Beginn der digitalen Leistung, bevor du dein erstes Event erstellst.",
|
||||||
|
"checkboxWaiver": "Ich verlange ausdrücklich, dass mit der Bereitstellung der digitalen Leistung jetzt begonnen wird. Mir ist bekannt, dass ich mein Widerrufsrecht verliere, sobald der Vertrag vollständig erfüllt ist.",
|
||||||
|
"errorWaiver": "Bitte bestätige den sofortigen Leistungsbeginn und das vorzeitige Erlöschen des Widerrufsrechts.",
|
||||||
|
"confirm": "Event erstellen",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"untitled": "Unbenanntes Event"
|
"untitled": "Unbenanntes Event"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2221,6 +2221,24 @@
|
|||||||
"checkoutMissing": "Checkout could not be started.",
|
"checkoutMissing": "Checkout could not be started.",
|
||||||
"checkoutFailed": "Add-on checkout failed."
|
"checkoutFailed": "Add-on checkout failed."
|
||||||
},
|
},
|
||||||
|
"legalConsent": {
|
||||||
|
"title": "Before purchase",
|
||||||
|
"description": "Please confirm the legal notes before buying an add-on.",
|
||||||
|
"checkboxTerms": "I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.",
|
||||||
|
"checkboxWaiver": "I expressly request that you begin providing the digital services (activation of my event add-on) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.",
|
||||||
|
"errorTerms": "Please confirm you accept the terms, privacy policy, and right of withdrawal.",
|
||||||
|
"errorWaiver": "Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.",
|
||||||
|
"confirm": "Continue to checkout",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"eventStartConsent": {
|
||||||
|
"title": "Before your first event",
|
||||||
|
"description": "Please confirm the immediate start of the digital service before creating your first event.",
|
||||||
|
"checkboxWaiver": "I expressly request that the digital service begins now and understand my right of withdrawal expires once the contract has been fully performed.",
|
||||||
|
"errorWaiver": "Please confirm the immediate start of the digital service and the early expiry of the right of withdrawal.",
|
||||||
|
"confirm": "Create event",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"untitled": "Untitled event"
|
"untitled": "Untitled event"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { YStack } from '@tamagui/stacks';
|
import { YStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import { useTheme } from '@tamagui/core';
|
||||||
import { MobileSheet } from './Sheet';
|
import { MobileSheet } from './Sheet';
|
||||||
import { CTAButton } from './Primitives';
|
import { CTAButton } from './Primitives';
|
||||||
|
|
||||||
@@ -36,9 +37,24 @@ export function LegalConsentSheet({
|
|||||||
copy,
|
copy,
|
||||||
t,
|
t,
|
||||||
}: LegalConsentSheetProps) {
|
}: LegalConsentSheetProps) {
|
||||||
|
const theme = useTheme();
|
||||||
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||||
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const checkboxAccent = String(theme.primary?.val ?? '#2563eb');
|
||||||
|
const checkboxBorder = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||||
|
const checkboxSurface = String(theme.surface?.val ?? '#ffffff');
|
||||||
|
const checkboxStyle = {
|
||||||
|
marginTop: 4,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
accentColor: checkboxAccent,
|
||||||
|
backgroundColor: checkboxSurface,
|
||||||
|
border: `1px solid ${checkboxBorder}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
appearance: 'auto',
|
||||||
|
WebkitAppearance: 'auto',
|
||||||
|
} as const;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -103,7 +119,7 @@ export function LegalConsentSheet({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={acceptedTerms}
|
checked={acceptedTerms}
|
||||||
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
||||||
style={{ marginTop: 4, width: 16, height: 16 }}
|
style={checkboxStyle}
|
||||||
/>
|
/>
|
||||||
<Text fontSize="$sm" color="#111827">
|
<Text fontSize="$sm" color="#111827">
|
||||||
{copy?.checkboxTerms ?? t(
|
{copy?.checkboxTerms ?? t(
|
||||||
@@ -119,7 +135,7 @@ export function LegalConsentSheet({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={acceptedWaiver}
|
checked={acceptedWaiver}
|
||||||
onChange={(event) => setAcceptedWaiver(event.target.checked)}
|
onChange={(event) => setAcceptedWaiver(event.target.checked)}
|
||||||
style={{ marginTop: 4, width: 16, height: 16 }}
|
style={checkboxStyle}
|
||||||
/>
|
/>
|
||||||
<Text fontSize="$sm" color="#111827">
|
<Text fontSize="$sm" color="#111827">
|
||||||
{copy?.checkboxWaiver ?? t(
|
{copy?.checkboxWaiver ?? t(
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
|
vi.mock('@tamagui/core', () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
primary: { val: '#2563eb' },
|
||||||
|
borderColor: { val: '#e5e7eb' },
|
||||||
|
surface: { val: '#ffffff' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/stacks', () => ({
|
||||||
|
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/text', () => ({
|
||||||
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||||
|
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LegalConsentSheet } from '../LegalConsentSheet';
|
||||||
|
|
||||||
|
describe('LegalConsentSheet', () => {
|
||||||
|
it('renders the required consent checkboxes when open', () => {
|
||||||
|
const { getAllByRole } = render(
|
||||||
|
<LegalConsentSheet
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
t={(key, fallback) => fallback ?? key}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getAllByRole('checkbox')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { resolveOnboardingRedirect } from './onboardingGuard';
|
import { resolveOnboardingRedirect } from './onboardingGuard';
|
||||||
import {
|
import {
|
||||||
|
ADMIN_WELCOME_BASE_PATH,
|
||||||
ADMIN_WELCOME_EVENT_PATH,
|
ADMIN_WELCOME_EVENT_PATH,
|
||||||
ADMIN_WELCOME_PACKAGES_PATH,
|
|
||||||
ADMIN_WELCOME_SUMMARY_PATH,
|
ADMIN_WELCOME_SUMMARY_PATH,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ describe('resolveOnboardingRedirect', () => {
|
|||||||
pathname: '/event-admin/mobile/dashboard',
|
pathname: '/event-admin/mobile/dashboard',
|
||||||
isWelcomePath: false,
|
isWelcomePath: false,
|
||||||
isBillingPath: false,
|
isBillingPath: false,
|
||||||
|
isOnboardingDismissed: false,
|
||||||
|
isOnboardingCompleted: false,
|
||||||
});
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -24,9 +26,11 @@ describe('resolveOnboardingRedirect', () => {
|
|||||||
hasEvents: false,
|
hasEvents: false,
|
||||||
hasActivePackage: false,
|
hasActivePackage: false,
|
||||||
selectedPackageId: null,
|
selectedPackageId: null,
|
||||||
pathname: ADMIN_WELCOME_PACKAGES_PATH,
|
pathname: ADMIN_WELCOME_BASE_PATH,
|
||||||
isWelcomePath: true,
|
isWelcomePath: true,
|
||||||
isBillingPath: false,
|
isBillingPath: false,
|
||||||
|
isOnboardingDismissed: false,
|
||||||
|
isOnboardingCompleted: false,
|
||||||
});
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -39,6 +43,8 @@ describe('resolveOnboardingRedirect', () => {
|
|||||||
pathname: '/event-admin/mobile/billing',
|
pathname: '/event-admin/mobile/billing',
|
||||||
isWelcomePath: false,
|
isWelcomePath: false,
|
||||||
isBillingPath: true,
|
isBillingPath: true,
|
||||||
|
isOnboardingDismissed: false,
|
||||||
|
isOnboardingCompleted: false,
|
||||||
});
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -51,6 +57,8 @@ describe('resolveOnboardingRedirect', () => {
|
|||||||
pathname: '/event-admin/mobile/dashboard',
|
pathname: '/event-admin/mobile/dashboard',
|
||||||
isWelcomePath: false,
|
isWelcomePath: false,
|
||||||
isBillingPath: false,
|
isBillingPath: false,
|
||||||
|
isOnboardingDismissed: false,
|
||||||
|
isOnboardingCompleted: false,
|
||||||
});
|
});
|
||||||
expect(result).toBe(ADMIN_WELCOME_EVENT_PATH);
|
expect(result).toBe(ADMIN_WELCOME_EVENT_PATH);
|
||||||
});
|
});
|
||||||
@@ -63,11 +71,13 @@ describe('resolveOnboardingRedirect', () => {
|
|||||||
pathname: '/event-admin/mobile/dashboard',
|
pathname: '/event-admin/mobile/dashboard',
|
||||||
isWelcomePath: false,
|
isWelcomePath: false,
|
||||||
isBillingPath: false,
|
isBillingPath: false,
|
||||||
|
isOnboardingDismissed: false,
|
||||||
|
isOnboardingCompleted: false,
|
||||||
});
|
});
|
||||||
expect(result).toBe(ADMIN_WELCOME_SUMMARY_PATH);
|
expect(result).toBe(ADMIN_WELCOME_SUMMARY_PATH);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to packages when no selection exists', () => {
|
it('redirects to landing when no selection exists', () => {
|
||||||
const result = resolveOnboardingRedirect({
|
const result = resolveOnboardingRedirect({
|
||||||
hasEvents: false,
|
hasEvents: false,
|
||||||
hasActivePackage: false,
|
hasActivePackage: false,
|
||||||
@@ -75,8 +85,10 @@ describe('resolveOnboardingRedirect', () => {
|
|||||||
pathname: '/event-admin/mobile/dashboard',
|
pathname: '/event-admin/mobile/dashboard',
|
||||||
isWelcomePath: false,
|
isWelcomePath: false,
|
||||||
isBillingPath: false,
|
isBillingPath: false,
|
||||||
|
isOnboardingDismissed: false,
|
||||||
|
isOnboardingCompleted: false,
|
||||||
});
|
});
|
||||||
expect(result).toBe(ADMIN_WELCOME_PACKAGES_PATH);
|
expect(result).toBe(ADMIN_WELCOME_BASE_PATH);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not redirect when already on target', () => {
|
it('does not redirect when already on target', () => {
|
||||||
@@ -84,9 +96,39 @@ describe('resolveOnboardingRedirect', () => {
|
|||||||
hasEvents: false,
|
hasEvents: false,
|
||||||
hasActivePackage: false,
|
hasActivePackage: false,
|
||||||
selectedPackageId: null,
|
selectedPackageId: null,
|
||||||
pathname: ADMIN_WELCOME_PACKAGES_PATH,
|
pathname: ADMIN_WELCOME_BASE_PATH,
|
||||||
isWelcomePath: false,
|
isWelcomePath: false,
|
||||||
isBillingPath: false,
|
isBillingPath: false,
|
||||||
|
isOnboardingDismissed: false,
|
||||||
|
isOnboardingCompleted: false,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not redirect when onboarding is dismissed', () => {
|
||||||
|
const result = resolveOnboardingRedirect({
|
||||||
|
hasEvents: false,
|
||||||
|
hasActivePackage: false,
|
||||||
|
selectedPackageId: null,
|
||||||
|
pathname: '/event-admin/mobile/dashboard',
|
||||||
|
isWelcomePath: false,
|
||||||
|
isBillingPath: false,
|
||||||
|
isOnboardingDismissed: true,
|
||||||
|
isOnboardingCompleted: false,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not redirect when onboarding is completed', () => {
|
||||||
|
const result = resolveOnboardingRedirect({
|
||||||
|
hasEvents: false,
|
||||||
|
hasActivePackage: false,
|
||||||
|
selectedPackageId: null,
|
||||||
|
pathname: '/event-admin/mobile/dashboard',
|
||||||
|
isWelcomePath: false,
|
||||||
|
isBillingPath: false,
|
||||||
|
isOnboardingDismissed: false,
|
||||||
|
isOnboardingCompleted: true,
|
||||||
});
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
ADMIN_WELCOME_BASE_PATH,
|
||||||
ADMIN_WELCOME_EVENT_PATH,
|
ADMIN_WELCOME_EVENT_PATH,
|
||||||
ADMIN_WELCOME_PACKAGES_PATH,
|
|
||||||
ADMIN_WELCOME_SUMMARY_PATH,
|
ADMIN_WELCOME_SUMMARY_PATH,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ type OnboardingRedirectInput = {
|
|||||||
pathname: string;
|
pathname: string;
|
||||||
isWelcomePath: boolean;
|
isWelcomePath: boolean;
|
||||||
isBillingPath: boolean;
|
isBillingPath: boolean;
|
||||||
|
isOnboardingDismissed?: boolean;
|
||||||
|
isOnboardingCompleted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveOnboardingRedirect({
|
export function resolveOnboardingRedirect({
|
||||||
@@ -20,11 +22,17 @@ export function resolveOnboardingRedirect({
|
|||||||
pathname,
|
pathname,
|
||||||
isWelcomePath,
|
isWelcomePath,
|
||||||
isBillingPath,
|
isBillingPath,
|
||||||
|
isOnboardingDismissed,
|
||||||
|
isOnboardingCompleted,
|
||||||
}: OnboardingRedirectInput): string | null {
|
}: OnboardingRedirectInput): string | null {
|
||||||
if (hasEvents) {
|
if (hasEvents) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOnboardingDismissed || isOnboardingCompleted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (isWelcomePath || isBillingPath) {
|
if (isWelcomePath || isBillingPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -34,7 +42,7 @@ export function resolveOnboardingRedirect({
|
|||||||
? ADMIN_WELCOME_EVENT_PATH
|
? ADMIN_WELCOME_EVENT_PATH
|
||||||
: shouldContinueSummary
|
: shouldContinueSummary
|
||||||
? ADMIN_WELCOME_SUMMARY_PATH
|
? ADMIN_WELCOME_SUMMARY_PATH
|
||||||
: ADMIN_WELCOME_PACKAGES_PATH;
|
: ADMIN_WELCOME_BASE_PATH;
|
||||||
|
|
||||||
if (pathname === target) {
|
if (pathname === target) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { SizableText as Text } from '@tamagui/text';
|
|||||||
import { OnboardingShell } from '../components/OnboardingShell';
|
import { OnboardingShell } from '../components/OnboardingShell';
|
||||||
import { MobileCard, CTAButton } from '../components/Primitives';
|
import { MobileCard, CTAButton } from '../components/Primitives';
|
||||||
import { ADMIN_HOME_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
|
import { ADMIN_HOME_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
|
||||||
import { getTenantPackagesOverview } from '../../api';
|
import { getTenantPackagesOverview, trackOnboarding } from '../../api';
|
||||||
import { getSelectedPackageId } from '../lib/onboardingSelection';
|
import { getSelectedPackageId } from '../lib/onboardingSelection';
|
||||||
|
|
||||||
export default function WelcomeEventPage() {
|
export default function WelcomeEventPage() {
|
||||||
@@ -24,6 +24,10 @@ export default function WelcomeEventPage() {
|
|||||||
|
|
||||||
const hasActivePackage =
|
const hasActivePackage =
|
||||||
Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
|
Boolean(overview?.activePackage) || Boolean(overview?.packages?.some((pkg) => pkg.active));
|
||||||
|
const handleSkip = React.useCallback(() => {
|
||||||
|
void trackOnboarding('dismissed');
|
||||||
|
navigate(ADMIN_HOME_PATH);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const backTarget = selectedId
|
const backTarget = selectedId
|
||||||
? ADMIN_WELCOME_SUMMARY_PATH
|
? ADMIN_WELCOME_SUMMARY_PATH
|
||||||
@@ -40,7 +44,7 @@ export default function WelcomeEventPage() {
|
|||||||
'Fill in a few details, invite co-hosts, and open your guest gallery for the big day.',
|
'Fill in a few details, invite co-hosts, and open your guest gallery for the big day.',
|
||||||
)}
|
)}
|
||||||
onBack={() => navigate(backTarget)}
|
onBack={() => navigate(backTarget)}
|
||||||
onSkip={() => navigate(ADMIN_HOME_PATH)}
|
onSkip={handleSkip}
|
||||||
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
||||||
>
|
>
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { YStack, XStack } from '@tamagui/stacks';
|
|||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
|
||||||
import { OnboardingShell } from '../components/OnboardingShell';
|
import { OnboardingShell } from '../components/OnboardingShell';
|
||||||
import { getTenantPackagesOverview } from '../../api';
|
import { getTenantPackagesOverview, trackOnboarding } from '../../api';
|
||||||
import { useEventContext } from '../../context/EventContext';
|
import { useEventContext } from '../../context/EventContext';
|
||||||
import {
|
import {
|
||||||
ADMIN_HOME_PATH,
|
ADMIN_HOME_PATH,
|
||||||
@@ -29,6 +29,10 @@ export default function WelcomeLandingPage() {
|
|||||||
|
|
||||||
const hasActivePackage =
|
const hasActivePackage =
|
||||||
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
|
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
|
||||||
|
const handleSkip = React.useCallback(() => {
|
||||||
|
void trackOnboarding('dismissed');
|
||||||
|
navigate(ADMIN_HOME_PATH);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingShell
|
<OnboardingShell
|
||||||
@@ -38,7 +42,7 @@ export default function WelcomeLandingPage() {
|
|||||||
'layout.subtitle',
|
'layout.subtitle',
|
||||||
'Begin with an inspired introduction, secure your package, and craft the perfect guest gallery – all optimised for mobile hosts.',
|
'Begin with an inspired introduction, secure your package, and craft the perfect guest gallery – all optimised for mobile hosts.',
|
||||||
)}
|
)}
|
||||||
onSkip={() => navigate(ADMIN_HOME_PATH)}
|
onSkip={handleSkip}
|
||||||
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
skipLabel={t('layout.jumpToDashboard', 'Jump to dashboard')}
|
||||||
>
|
>
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ADMIN_PUBLIC_LANDING_PATH,
|
ADMIN_PUBLIC_LANDING_PATH,
|
||||||
ADMIN_WELCOME_BASE_PATH,
|
ADMIN_WELCOME_BASE_PATH,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { getTenantPackagesOverview } from './api';
|
import { fetchOnboardingStatus, getTenantPackagesOverview } from './api';
|
||||||
import { getSelectedPackageId } from './mobile/lib/onboardingSelection';
|
import { getSelectedPackageId } from './mobile/lib/onboardingSelection';
|
||||||
import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard';
|
import { resolveOnboardingRedirect } from './mobile/lib/onboardingGuard';
|
||||||
const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
|
const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
|
||||||
@@ -47,14 +47,15 @@ const MobileWelcomeSummaryPage = React.lazy(() => import('./mobile/welcome/Welco
|
|||||||
const MobileWelcomeEventPage = React.lazy(() => import('./mobile/welcome/WelcomeEventPage'));
|
const MobileWelcomeEventPage = React.lazy(() => import('./mobile/welcome/WelcomeEventPage'));
|
||||||
|
|
||||||
function RequireAuth() {
|
function RequireAuth() {
|
||||||
const { status } = useAuth();
|
const { status, user } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { hasEvents, isLoading: eventsLoading } = useEventContext();
|
const { hasEvents, isLoading: eventsLoading } = useEventContext();
|
||||||
const selectedPackageId = getSelectedPackageId();
|
const selectedPackageId = getSelectedPackageId();
|
||||||
const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH);
|
const isWelcomePath = location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH);
|
||||||
const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH);
|
const isBillingPath = location.pathname.startsWith(ADMIN_BILLING_PATH);
|
||||||
|
const isTenantAdmin = Boolean(user && user.role !== 'member');
|
||||||
const shouldCheckPackages =
|
const shouldCheckPackages =
|
||||||
status === 'authenticated' && !eventsLoading && !hasEvents && !isWelcomePath && !isBillingPath;
|
status === 'authenticated' && isTenantAdmin && !eventsLoading && !hasEvents && !isWelcomePath && !isBillingPath;
|
||||||
|
|
||||||
const { data: packagesData, isLoading: packagesLoading } = useQuery({
|
const { data: packagesData, isLoading: packagesLoading } = useQuery({
|
||||||
queryKey: ['mobile', 'onboarding', 'packages-overview'],
|
queryKey: ['mobile', 'onboarding', 'packages-overview'],
|
||||||
@@ -63,8 +64,18 @@ function RequireAuth() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: onboardingStatus, isLoading: onboardingLoading } = useQuery({
|
||||||
|
queryKey: ['mobile', 'onboarding', 'status'],
|
||||||
|
queryFn: fetchOnboardingStatus,
|
||||||
|
enabled: shouldCheckPackages,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const hasActivePackage =
|
const hasActivePackage =
|
||||||
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
|
Boolean(packagesData?.activePackage) || Boolean(packagesData?.packages?.some((pkg) => pkg.active));
|
||||||
|
const isOnboardingDismissed = Boolean(onboardingStatus?.steps?.dismissed_at);
|
||||||
|
const isOnboardingCompleted = Boolean(onboardingStatus?.steps?.completed_at);
|
||||||
|
const shouldBlockOnboarding = shouldCheckPackages && onboardingLoading;
|
||||||
|
|
||||||
const redirectTarget = resolveOnboardingRedirect({
|
const redirectTarget = resolveOnboardingRedirect({
|
||||||
hasEvents,
|
hasEvents,
|
||||||
@@ -73,6 +84,8 @@ function RequireAuth() {
|
|||||||
pathname: location.pathname,
|
pathname: location.pathname,
|
||||||
isWelcomePath,
|
isWelcomePath,
|
||||||
isBillingPath,
|
isBillingPath,
|
||||||
|
isOnboardingDismissed,
|
||||||
|
isOnboardingCompleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
@@ -87,7 +100,7 @@ function RequireAuth() {
|
|||||||
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
|
return <Navigate to={ADMIN_LOGIN_START_PATH} state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isWelcomePath && !isBillingPath && (eventsLoading || packagesLoading)) {
|
if (!isWelcomePath && !isBillingPath && (eventsLoading || packagesLoading || shouldBlockOnboarding)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||||
Bitte warten ...
|
Bitte warten ...
|
||||||
|
|||||||
50
tests/Feature/Api/Tenant/OnboardingStatusTest.php
Normal file
50
tests/Feature/Api/Tenant/OnboardingStatusTest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Tests\Feature\Tenant\TenantTestCase;
|
||||||
|
|
||||||
|
class OnboardingStatusTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
public function test_tenant_can_dismiss_onboarding(): void
|
||||||
|
{
|
||||||
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/onboarding', [
|
||||||
|
'step' => 'dismissed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$this->tenant->refresh();
|
||||||
|
|
||||||
|
$dismissedAt = Arr::get($this->tenant->settings ?? [], 'onboarding.dismissed_at');
|
||||||
|
|
||||||
|
$this->assertNotNull($dismissedAt);
|
||||||
|
|
||||||
|
$show = $this->authenticatedRequest('GET', '/api/v1/tenant/onboarding');
|
||||||
|
|
||||||
|
$show->assertOk();
|
||||||
|
$show->assertJsonPath('steps.dismissed_at', $dismissedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tenant_can_mark_onboarding_completed(): void
|
||||||
|
{
|
||||||
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/onboarding', [
|
||||||
|
'step' => 'completed',
|
||||||
|
'meta' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$this->tenant->refresh();
|
||||||
|
|
||||||
|
$completedAt = Arr::get($this->tenant->settings ?? [], 'onboarding.completed_at');
|
||||||
|
|
||||||
|
$this->assertNotNull($completedAt);
|
||||||
|
|
||||||
|
$show = $this->authenticatedRequest('GET', '/api/v1/tenant/onboarding');
|
||||||
|
|
||||||
|
$show->assertOk();
|
||||||
|
$show->assertJsonPath('steps.completed_at', $completedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/Feature/Console/ScheduleConfigurationTest.php
Normal file
42
tests/Feature/Console/ScheduleConfigurationTest.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Console;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ScheduleConfigurationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_storage_and_checkout_commands_are_scheduled(): void
|
||||||
|
{
|
||||||
|
Artisan::call('schedule:list', [
|
||||||
|
'--json' => true,
|
||||||
|
'--no-interaction' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tasks = json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$this->assertScheduledCommand($tasks, 'storage:monitor', '*/5 * * * *');
|
||||||
|
$this->assertScheduledCommand($tasks, 'storage:check-upload-queues', '*/5 * * * *');
|
||||||
|
$this->assertScheduledCommand($tasks, 'storage:archive-pending', '0 1 * * *');
|
||||||
|
$this->assertScheduledCommand($tasks, 'checkout:send-reminders', '0 * * * *');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $tasks
|
||||||
|
*/
|
||||||
|
private function assertScheduledCommand(array $tasks, string $command, string $expression): void
|
||||||
|
{
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
$taskCommand = (string) ($task['command'] ?? '');
|
||||||
|
if ($taskCommand !== '' && Str::contains($taskCommand, $command)) {
|
||||||
|
$this->assertSame($expression, $task['expression'] ?? null);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->fail(sprintf('Scheduled command not found: %s', $command));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
|
use Illuminate\Queue\Events\JobFailed;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -55,4 +59,156 @@ class SentryReportingTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(0, $fake->captured, 'Sentry should ignore 4xx errors');
|
$this->assertSame(0, $fake->captured, 'Sentry should ignore 4xx errors');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_reports_scheduled_task_failures_to_sentry(): void
|
||||||
|
{
|
||||||
|
$fake = new class
|
||||||
|
{
|
||||||
|
public int $captured = 0;
|
||||||
|
|
||||||
|
public function captureException(mixed $exception = null): void
|
||||||
|
{
|
||||||
|
$this->captured++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->app->instance('sentry', $fake);
|
||||||
|
|
||||||
|
Artisan::call('schedule:list', [
|
||||||
|
'--json' => true,
|
||||||
|
'--no-interaction' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$schedule = $this->app->make(Schedule::class);
|
||||||
|
$task = collect($schedule->events())
|
||||||
|
->first(fn ($event) => str_contains((string) $event->command, 'storage:monitor'));
|
||||||
|
|
||||||
|
$this->assertNotNull($task, 'Expected scheduled storage:monitor task');
|
||||||
|
|
||||||
|
$task->finish($this->app, 1);
|
||||||
|
|
||||||
|
$this->assertSame(1, $fake->captured, 'Sentry should receive scheduled task failures');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reports_queue_failures_to_sentry(): void
|
||||||
|
{
|
||||||
|
$fake = new class
|
||||||
|
{
|
||||||
|
public int $captured = 0;
|
||||||
|
|
||||||
|
public function captureException(mixed $exception = null): void
|
||||||
|
{
|
||||||
|
$this->captured++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->app->instance('sentry', $fake);
|
||||||
|
|
||||||
|
$job = new class implements \Illuminate\Contracts\Queue\Job
|
||||||
|
{
|
||||||
|
public function uuid(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJobId(): string
|
||||||
|
{
|
||||||
|
return 'job-id';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payload(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fire(): void {}
|
||||||
|
|
||||||
|
public function release($delay = 0): void {}
|
||||||
|
|
||||||
|
public function isReleased(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): void {}
|
||||||
|
|
||||||
|
public function isDeleted(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDeletedOrReleased(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attempts(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasFailed(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsFailed(): void {}
|
||||||
|
|
||||||
|
public function fail($e = null): void {}
|
||||||
|
|
||||||
|
public function maxTries(): ?int
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxExceptions(): ?int
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function timeout(): ?int
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryUntil(): ?int
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'FakeJob';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveName(): string
|
||||||
|
{
|
||||||
|
return 'FakeJob';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveQueuedJobClass(): string
|
||||||
|
{
|
||||||
|
return 'FakeJob';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnectionName(): string
|
||||||
|
{
|
||||||
|
return 'redis';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQueue(): string
|
||||||
|
{
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRawBody(): string
|
||||||
|
{
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Event::dispatch(new JobFailed('redis', $job, new \RuntimeException('Queue failure')));
|
||||||
|
|
||||||
|
$this->assertSame(1, $fake->captured, 'Sentry should receive queue failures');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,17 @@ class EventAddonCheckoutTest extends TenantTestCase
|
|||||||
|
|
||||||
// Fake Paddle response
|
// Fake Paddle response
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'*/checkout/links' => Http::response([
|
'*/customers' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
'url' => 'https://checkout.paddle.test/abcd',
|
'id' => 'ctm_addon_123',
|
||||||
'id' => 'chk_addon_123',
|
],
|
||||||
'expires_at' => now()->addHour()->toIso8601String(),
|
], 200),
|
||||||
|
'*/transactions' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
'id' => 'txn_addon_123',
|
||||||
|
'checkout' => [
|
||||||
|
'url' => 'https://checkout.paddle.test/abcd',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
@@ -67,14 +73,14 @@ class EventAddonCheckoutTest extends TenantTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertJsonPath('checkout_id', 'chk_addon_123');
|
$response->assertJsonPath('checkout_id', 'txn_addon_123');
|
||||||
|
|
||||||
$this->assertDatabaseHas('event_package_addons', [
|
$this->assertDatabaseHas('event_package_addons', [
|
||||||
'event_package_id' => $eventPackage->id,
|
'event_package_id' => $eventPackage->id,
|
||||||
'addon_key' => 'extra_photos_small',
|
'addon_key' => 'extra_photos_small',
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
'quantity' => 2,
|
'quantity' => 2,
|
||||||
'checkout_id' => 'chk_addon_123',
|
'transaction_id' => 'txn_addon_123',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();
|
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();
|
||||||
|
|||||||
@@ -37,12 +37,18 @@ class GiftVoucherCheckoutServiceTest extends TestCase
|
|||||||
$client = Mockery::mock(PaddleClient::class);
|
$client = Mockery::mock(PaddleClient::class);
|
||||||
$client->shouldReceive('post')
|
$client->shouldReceive('post')
|
||||||
->once()
|
->once()
|
||||||
->with('/checkout/links', Mockery::on(function ($payload) {
|
->with('/customers', Mockery::on(function ($payload) {
|
||||||
|
return $payload['email'] === 'buyer@example.com';
|
||||||
|
}))
|
||||||
|
->andReturn(['data' => ['id' => 'ctm_123']]);
|
||||||
|
$client->shouldReceive('post')
|
||||||
|
->once()
|
||||||
|
->with('/transactions', Mockery::on(function ($payload) {
|
||||||
return $payload['items'][0]['price_id'] === 'pri_a'
|
return $payload['items'][0]['price_id'] === 'pri_a'
|
||||||
&& $payload['customer_email'] === 'buyer@example.com'
|
&& $payload['customer_id'] === 'ctm_123'
|
||||||
&& $payload['custom_data']['type'] === 'gift_voucher';
|
&& $payload['custom_data']['type'] === 'gift_voucher';
|
||||||
}))
|
}))
|
||||||
->andReturn(['data' => ['url' => 'https://paddle.test/checkout/123', 'expires_at' => '2025-12-31T00:00:00Z', 'id' => 'chk_123']]);
|
->andReturn(['data' => ['checkout' => ['url' => 'https://paddle.test/checkout/123'], 'id' => 'txn_123']]);
|
||||||
|
|
||||||
$this->app->instance(PaddleClient::class, $client);
|
$this->app->instance(PaddleClient::class, $client);
|
||||||
|
|
||||||
@@ -57,6 +63,6 @@ class GiftVoucherCheckoutServiceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
|
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
|
||||||
$this->assertSame('chk_123', $checkout['id']);
|
$this->assertSame('txn_123', $checkout['id']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,15 +35,18 @@ class PaddleCheckoutServiceTest extends TestCase
|
|||||||
|
|
||||||
$client->shouldReceive('post')
|
$client->shouldReceive('post')
|
||||||
->once()
|
->once()
|
||||||
->with('/checkout/links', Mockery::on(function (array $payload) use ($tenant, $package) {
|
->with('/transactions', Mockery::on(function (array $payload) use ($tenant, $package) {
|
||||||
return $payload['items'][0]['price_id'] === 'pri_123'
|
return $payload['items'][0]['price_id'] === 'pri_123'
|
||||||
&& $payload['customer_id'] === 'ctm_123'
|
&& $payload['customer_id'] === 'ctm_123'
|
||||||
&& ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id
|
&& ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id
|
||||||
&& ($payload['custom_data']['package_id'] ?? null) === (string) $package->id
|
&& ($payload['custom_data']['package_id'] ?? null) === (string) $package->id
|
||||||
&& ($payload['custom_data']['source'] ?? null) === 'test'
|
&& ($payload['custom_data']['source'] ?? null) === 'test'
|
||||||
&& ! isset($payload['metadata']);
|
&& ! isset($payload['metadata'])
|
||||||
|
&& ! isset($payload['success_url'])
|
||||||
|
&& ! isset($payload['cancel_url'])
|
||||||
|
&& ! isset($payload['customer_email']);
|
||||||
}))
|
}))
|
||||||
->andReturn(['data' => ['url' => 'https://paddle.test/checkout/123', 'id' => 'chk_123']]);
|
->andReturn(['data' => ['checkout' => ['url' => 'https://paddle.test/checkout/123'], 'id' => 'txn_123']]);
|
||||||
|
|
||||||
$this->app->instance(PaddleClient::class, $client);
|
$this->app->instance(PaddleClient::class, $client);
|
||||||
$this->app->instance(PaddleCustomerService::class, $customers);
|
$this->app->instance(PaddleCustomerService::class, $customers);
|
||||||
@@ -57,6 +60,6 @@ class PaddleCheckoutServiceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
|
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
|
||||||
$this->assertSame('chk_123', $checkout['id']);
|
$this->assertSame('txn_123', $checkout['id']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user