diff --git a/.env.example b/.env.example index f3cf3a5..708437d 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,7 @@ MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +MAIL_OPS=info@fotospiel.app AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/app/Filament/Resources/PurchaseResource.php b/app/Filament/Resources/PurchaseResource.php index a13ed49..72617bf 100644 --- a/app/Filament/Resources/PurchaseResource.php +++ b/app/Filament/Resources/PurchaseResource.php @@ -23,7 +23,12 @@ use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; +use App\Notifications\Ops\RefundProcessed; +use App\Notifications\Customer\RefundReceipt; +use App\Services\Paddle\PaddleTransactionService; class PurchaseResource extends Resource { @@ -125,6 +130,21 @@ class PurchaseResource extends Resource TextColumn::make('provider_id') ->copyable() ->toggleable(), + TextColumn::make('metadata.consents.legal_version') + ->label('Legal Version') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('metadata.consents.accepted_terms_at') + ->label('Terms accepted') + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('metadata.consents.accepted_withdrawal_notice_at') + ->label('Withdrawal notice') + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('metadata.consents.digital_content_waiver_at') + ->label('Waiver (digital)') + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('type') @@ -162,10 +182,55 @@ class PurchaseResource extends Resource ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() ->visible(fn (PackagePurchase $record): bool => ! $record->refunded) - ->action(function (PackagePurchase $record) { - $record->update(['refunded' => true]); - // TODO: Call Stripe/Paddle API for actual refund - Log::info('Refund processed for purchase ID: '.$record->id); + ->form([ + Textarea::make('reason') + ->label('Refund reason (optional)') + ->rows(2), + ]) + ->action(function (PackagePurchase $record, array $data) { + $reason = $data['reason'] ?? null; + $refundSuccess = true; + $errorMessage = null; + + if ($record->provider === 'paddle' && $record->provider_id) { + try { + /** @var PaddleTransactionService $paddle */ + $paddle = App::make(PaddleTransactionService::class); + $paddle->refund($record->provider_id, ['reason' => $reason]); + } catch (\Throwable $exception) { + $refundSuccess = false; + $errorMessage = $exception->getMessage(); + Log::warning('Paddle refund failed', [ + 'purchase_id' => $record->id, + 'provider_id' => $record->provider_id, + 'error' => $exception->getMessage(), + ]); + } + } + + $metadata = $record->metadata ?? []; + $metadata['refund_reason'] = $reason ?: ($metadata['refund_reason'] ?? null); + + $record->update([ + 'refunded' => true, + 'metadata' => $metadata, + ]); + + Log::info('Refund processed for purchase ID: '.$record->id, [ + 'provider' => $record->provider, + 'provider_id' => $record->provider_id, + 'reason' => $reason, + ]); + + $customerEmail = $record->tenant->contact_email ?? $record->tenant?->user?->email; + if ($customerEmail) { + Notification::route('mail', $customerEmail)->notify(new RefundReceipt($record, $reason)); + } + + $opsEmail = config('mail.ops_address'); + if ($opsEmail) { + Notification::route('mail', $opsEmail)->notify(new RefundProcessed($record, $refundSuccess, $reason, $errorMessage)); + } }), ]) ->bulkActions([ diff --git a/app/Http/Controllers/Api/LegalController.php b/app/Http/Controllers/Api/LegalController.php index bae47c9..0e208a7 100644 --- a/app/Http/Controllers/Api/LegalController.php +++ b/app/Http/Controllers/Api/LegalController.php @@ -38,18 +38,10 @@ class LegalController extends BaseController public function show(Request $request, string $slug) { $locale = $request->query('lang', 'de'); - - // Support common English aliases as fallbacks - $s = strtolower($slug); - $aliasMap = [ - 'imprint' => 'impressum', - 'privacy' => 'datenschutz', - 'terms' => 'agb', - ]; - $resolved = $aliasMap[$s] ?? $s; + $slugs = $this->resolveSlugs($slug); $page = LegalPage::query() - ->where('slug', $resolved) + ->whereIn('slug', $slugs) ->where('is_published', true) ->orderByDesc('version') ->first(); @@ -59,7 +51,7 @@ class LegalController extends BaseController 'Legal Page Not Found', 'The requested legal document does not exist.', Response::HTTP_NOT_FOUND, - ['slug' => $resolved] + ['slug' => $slugs[0]] ); } @@ -77,6 +69,28 @@ class LegalController extends BaseController ])->header('Cache-Control', 'no-store'); } + protected function resolveSlugs(string $slug): array + { + $s = strtolower($slug); + $aliasMap = [ + 'imprint' => 'impressum', + 'privacy' => 'datenschutz', + 'terms' => 'agb', + 'withdrawal' => 'widerrufsbelehrung', + 'cancellation' => 'widerrufsbelehrung', + 'cancellation-policy' => 'widerrufsbelehrung', + 'widerruf' => 'widerrufsbelehrung', + ]; + + $canonical = $aliasMap[$s] ?? $s; + + return array_values(array_unique( + $canonical === 'widerrufsbelehrung' + ? ['widerrufsbelehrung', 'widerruf'] + : [$canonical] + )); + } + protected function convertMarkdownToHtml(string $markdown): string { return trim((string) $this->markdown->convert($markdown)); diff --git a/app/Http/Controllers/LegalPageController.php b/app/Http/Controllers/LegalPageController.php index a659e9d..ccaecdb 100644 --- a/app/Http/Controllers/LegalPageController.php +++ b/app/Http/Controllers/LegalPageController.php @@ -20,12 +20,12 @@ class LegalPageController extends Controller { public function show(Request $request, string $locale, ?string $slug = null): Response { - $resolvedSlug = $this->resolveSlug($slug); + $slugCandidates = $this->resolveSlugs($slug); $page = null; try { $page = LegalPage::query() - ->where('slug', $resolvedSlug) + ->whereIn('slug', $slugCandidates) ->where('is_published', true) ->orderByDesc('version') ->first(); @@ -37,7 +37,7 @@ class LegalPageController extends Controller $locale = $request->route('locale', app()->getLocale()); if (! $page) { - $fallback = $this->loadFallbackDocument($resolvedSlug, $locale); + $fallback = $this->loadFallbackDocument($slugCandidates, $locale); if (! $fallback) { abort(404); @@ -50,7 +50,7 @@ class LegalPageController extends Controller 'effectiveFrom' => null, 'effectiveFromLabel' => null, 'versionLabel' => null, - 'slug' => $resolvedSlug, + 'slug' => $slugCandidates[0], ]); } @@ -58,7 +58,8 @@ class LegalPageController extends Controller ?? $page->title[$page->locale_fallback] ?? $page->title['de'] ?? $page->title['en'] - ?? Str::title($resolvedSlug); + ?? Str::title($slugCandidates[0]); + $title = Str::ucfirst($title); $bodyMarkdown = $page->body_markdown[$locale] ?? $page->body_markdown[$page->locale_fallback] @@ -76,11 +77,11 @@ class LegalPageController extends Controller ? __('legal.effective_from', ['date' => $effectiveFrom->translatedFormat('d. F Y')]) : null, 'versionLabel' => __('legal.version', ['version' => $page->version]), - 'slug' => $resolvedSlug, + 'slug' => $slugCandidates[0], ]); } - private function resolveSlug(?string $slug): string + private function resolveSlugs(?string $slug): array { $slug = strtolower($slug ?? ''); @@ -88,9 +89,20 @@ class LegalPageController extends Controller 'imprint' => 'impressum', 'privacy' => 'datenschutz', 'terms' => 'agb', + 'withdrawal' => 'widerrufsbelehrung', + 'cancellation' => 'widerrufsbelehrung', + 'cancellation-policy' => 'widerrufsbelehrung', + 'widerruf' => 'widerrufsbelehrung', ]; - return $aliases[$slug] ?? $slug ?: 'impressum'; + $canonical = $aliases[$slug] ?? $slug ?: 'impressum'; + + // Support both slugs for withdrawal in case DB uses a shorter slug + $fallbacks = $canonical === 'widerrufsbelehrung' + ? ['widerrufsbelehrung', 'widerruf'] + : [$canonical]; + + return array_values(array_unique($fallbacks)); } private function convertMarkdownToHtml(string $markdown): string @@ -111,7 +123,7 @@ class LegalPageController extends Controller return trim((string) $converter->convert($markdown)); } - private function loadFallbackDocument(string $slug, string $locale): ?array + private function loadFallbackDocument(array $slugCandidates, string $locale): ?array { $candidates = array_unique([ strtolower($locale), @@ -120,20 +132,22 @@ class LegalPageController extends Controller 'en', ]); - foreach ($candidates as $candidateLocale) { - $path = base_path("docs/legal/{$slug}-{$candidateLocale}.md"); + foreach ($slugCandidates as $slug) { + foreach ($candidates as $candidateLocale) { + $path = base_path("docs/legal/{$slug}-{$candidateLocale}.md"); - if (! is_file($path)) { - continue; + if (! is_file($path)) { + continue; + } + + $markdown = (string) file_get_contents($path); + $title = $this->extractTitleFromMarkdown($markdown) ?? Str::title($slug); + + return [ + 'markdown' => $markdown, + 'title' => $title, + ]; } - - $markdown = (string) file_get_contents($path); - $title = $this->extractTitleFromMarkdown($markdown) ?? Str::title($slug); - - return [ - 'markdown' => $markdown, - 'title' => $title, - ]; } return null; diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 4753a73..ffe8d41 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -132,6 +132,13 @@ class MarketingController extends Controller Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]); $package = Package::findOrFail($packageId); + $requiresWaiver = (bool) ($package->activates_immediately ?? true); + + $request->validate([ + 'accepted_terms' => ['sometimes', 'boolean', 'accepted'], + 'accepted_waiver' => ['sometimes', 'boolean'], + ]); + $couponCode = $this->rememberCouponFromRequest($request, $package); if (! Auth::check()) { @@ -194,6 +201,16 @@ class MarketingController extends Controller $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + $now = now(); + + $session->forceFill([ + 'accepted_terms_at' => $request->boolean('accepted_terms') ? $now : null, + 'accepted_privacy_at' => $request->boolean('accepted_terms') ? $now : null, + 'accepted_withdrawal_notice_at' => $request->boolean('accepted_terms') ? $now : null, + 'digital_content_waiver_at' => $requiresWaiver && $request->boolean('accepted_waiver') ? $now : null, + 'legal_version' => $this->resolveLegalVersion(), + ])->save(); + $appliedDiscountId = null; if ($couponCode) { @@ -219,6 +236,9 @@ class MarketingController extends Controller 'metadata' => [ 'checkout_session_id' => $session->id, 'coupon_code' => $couponCode, + 'legal_version' => $session->legal_version, + 'accepted_terms' => (bool) $session->accepted_terms_at, + 'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at, ], 'discount_id' => $appliedDiscountId, ]); @@ -628,4 +648,9 @@ class MarketingController extends Controller 'excerpt_html' => $this->convertMarkdownToHtml($excerpt), ]; } + + protected function resolveLegalVersion(): string + { + return config('app.legal_version', now()->toDateString()); + } } diff --git a/app/Http/Controllers/PaddleCheckoutController.php b/app/Http/Controllers/PaddleCheckoutController.php index 4bcdae3..ecd1ea7 100644 --- a/app/Http/Controllers/PaddleCheckoutController.php +++ b/app/Http/Controllers/PaddleCheckoutController.php @@ -29,6 +29,8 @@ class PaddleCheckoutController extends Controller 'return_url' => ['nullable', 'url'], 'inline' => ['sometimes', 'boolean'], 'coupon_code' => ['nullable', 'string', 'max:64'], + 'accepted_terms' => ['required', 'boolean', 'accepted'], + 'accepted_waiver' => ['sometimes', 'boolean'], ]); $user = Auth::user(); @@ -44,12 +46,30 @@ class PaddleCheckoutController extends Controller throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); } + $requiresWaiver = (bool) ($package->activates_immediately ?? true); + + if ($requiresWaiver && ! $request->boolean('accepted_waiver')) { + throw ValidationException::withMessages([ + 'accepted_waiver' => 'Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.', + ]); + } + $session = $this->sessions->createOrResume($user, $package, [ 'tenant' => $tenant, ]); $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + $now = now(); + + $session->forceFill([ + 'accepted_terms_at' => $now, + 'accepted_privacy_at' => $now, + 'accepted_withdrawal_notice_at' => $now, + 'digital_content_waiver_at' => $requiresWaiver ? $now : null, + 'legal_version' => $this->resolveLegalVersion(), + ])->save(); + $couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? ''))); $discountId = null; @@ -80,6 +100,9 @@ class PaddleCheckoutController extends Controller 'tenant_id' => (string) $tenant->id, 'package_id' => (string) $package->id, 'checkout_session_id' => (string) $session->id, + 'legal_version' => $session->legal_version, + 'accepted_terms' => '1', + 'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver') ? '1' : '0', ], 'customer' => array_filter([ 'email' => $user->email, @@ -94,6 +117,9 @@ class PaddleCheckoutController extends Controller 'metadata' => [ 'checkout_session_id' => $session->id, 'coupon_code' => $couponCode ?: null, + 'legal_version' => $session->legal_version, + 'accepted_terms' => true, + 'accepted_waiver' => $requiresWaiver && $request->boolean('accepted_waiver'), ], 'discount_id' => $discountId, ]); @@ -109,4 +135,9 @@ class PaddleCheckoutController extends Controller return response()->json($checkout); } + + protected function resolveLegalVersion(): string + { + return config('app.legal_version', now()->toDateString()); + } } diff --git a/app/Http/Requests/Tenant/EventAddonCheckoutRequest.php b/app/Http/Requests/Tenant/EventAddonCheckoutRequest.php index 16e560f..c194709 100644 --- a/app/Http/Requests/Tenant/EventAddonCheckoutRequest.php +++ b/app/Http/Requests/Tenant/EventAddonCheckoutRequest.php @@ -27,6 +27,9 @@ class EventAddonCheckoutRequest extends FormRequest 'quantity' => ['nullable', 'integer', 'min:1', 'max:50'], 'success_url' => ['nullable', 'url'], 'cancel_url' => ['nullable', 'url'], + 'accepted_terms' => ['required', 'boolean', 'accepted'], + // Add-ons werden sofort erbracht -> Widerruf erlischt nur mit ausdrücklicher Zustimmung + 'accepted_waiver' => ['required', 'boolean', 'accepted'], ]; } } diff --git a/app/Models/CheckoutSession.php b/app/Models/CheckoutSession.php index 65230f8..869d0e7 100644 --- a/app/Models/CheckoutSession.php +++ b/app/Models/CheckoutSession.php @@ -62,6 +62,10 @@ class CheckoutSession extends Model 'discount_breakdown' => 'array', 'expires_at' => 'datetime', 'completed_at' => 'datetime', + 'accepted_terms_at' => 'datetime', + 'accepted_privacy_at' => 'datetime', + 'accepted_withdrawal_notice_at' => 'datetime', + 'digital_content_waiver_at' => 'datetime', 'amount_subtotal' => 'decimal:2', 'amount_total' => 'decimal:2', 'amount_discount' => 'decimal:2', diff --git a/app/Models/Package.php b/app/Models/Package.php index f395d5f..cb2133c 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -57,6 +57,10 @@ class Package extends Model 'paddle_snapshot' => 'array', ]; + protected $appends = [ + 'activates_immediately', + ]; + protected function features(): Attribute { return Attribute::make( @@ -140,4 +144,10 @@ class Package extends Model 'max_events_per_year' => $this->max_events_per_year, ]; } + + public function getActivatesImmediatelyAttribute(): bool + { + // Default: Pakete werden nach Kauf sofort freigeschaltet (digitale Dienstleistung). + return true; + } } diff --git a/app/Notifications/Customer/RefundReceipt.php b/app/Notifications/Customer/RefundReceipt.php new file mode 100644 index 0000000..d7d62df --- /dev/null +++ b/app/Notifications/Customer/RefundReceipt.php @@ -0,0 +1,46 @@ +purchase->tenant; + $package = $this->purchase->package; + $amount = number_format((float) $this->purchase->price, 2); + + $mail = (new MailMessage) + ->subject(__('emails.refund.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')])) + ->greeting(__('emails.refund.greeting', ['name' => $tenant?->name ?? __('emails.package_limits.team_fallback')])) + ->line(__('emails.refund.body', [ + 'amount' => $amount, + 'currency' => '€', + 'provider_id' => $this->purchase->provider_id ?? '—', + ])); + + if ($this->reason) { + $mail->line(__('emails.refund.reason', ['reason' => $this->reason])); + } + + return $mail->line(__('emails.refund.footer')); + } +} diff --git a/app/Notifications/Ops/AddonPurchased.php b/app/Notifications/Ops/AddonPurchased.php new file mode 100644 index 0000000..6cd2e25 --- /dev/null +++ b/app/Notifications/Ops/AddonPurchased.php @@ -0,0 +1,43 @@ +addon->event; + $tenant = $event?->tenant; + $label = $this->addon->metadata['label'] ?? $this->addon->addon_key; + $amount = $this->addon->amount ? number_format((float) $this->addon->amount, 2) : null; + $currency = $this->addon->currency ?? 'EUR'; + + return (new MailMessage) + ->subject(__('emails.ops.addon.subject', ['addon' => $label])) + ->greeting(__('emails.ops.addon.greeting')) + ->line(__('emails.ops.addon.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')])) + ->line(__('emails.ops.addon.event', ['event' => $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback')])) + ->line(__('emails.ops.addon.addon', ['addon' => $label, 'quantity' => $this->addon->quantity])) + ->when($amount, fn ($mail) => $mail->line(__('emails.ops.addon.amount', ['amount' => $amount, 'currency' => $currency]))) + ->line(__('emails.ops.addon.provider', [ + 'checkout' => $this->addon->checkout_id ?? '—', + 'transaction' => $this->addon->transaction_id ?? '—', + ])) + ->line(__('emails.ops.addon.footer')); + } +} diff --git a/app/Notifications/Ops/PurchaseCreated.php b/app/Notifications/Ops/PurchaseCreated.php new file mode 100644 index 0000000..e296aeb --- /dev/null +++ b/app/Notifications/Ops/PurchaseCreated.php @@ -0,0 +1,43 @@ +purchase->tenant; + $package = $this->purchase->package; + $amount = number_format((float) $this->purchase->price, 2); + $consents = $this->purchase->metadata['consents'] ?? []; + + return (new MailMessage) + ->subject(__('emails.ops.purchase.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')])) + ->greeting(__('emails.ops.purchase.greeting')) + ->line(__('emails.ops.purchase.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')])) + ->line(__('emails.ops.purchase.package', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')])) + ->line(__('emails.ops.purchase.amount', ['amount' => $amount, 'currency' => '€'])) + ->line(__('emails.ops.purchase.provider', ['provider' => $this->purchase->provider, 'id' => $this->purchase->provider_id ?? '—'])) + ->line(__('emails.ops.purchase.consents', [ + 'legal' => $consents['legal_version'] ?? 'n/a', + 'terms' => $consents['accepted_terms_at'] ?? 'n/a', + 'waiver' => $consents['digital_content_waiver_at'] ?? 'n/a', + ])) + ->line(__('emails.ops.purchase.footer')); + } +} diff --git a/app/Notifications/Ops/RefundProcessed.php b/app/Notifications/Ops/RefundProcessed.php new file mode 100644 index 0000000..1d5061b --- /dev/null +++ b/app/Notifications/Ops/RefundProcessed.php @@ -0,0 +1,52 @@ +purchase->tenant; + $package = $this->purchase->package; + $amount = number_format((float) $this->purchase->price, 2); + + $mail = (new MailMessage) + ->subject(__('emails.ops.refund.subject', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')])) + ->greeting(__('emails.ops.refund.greeting')) + ->line(__('emails.ops.refund.tenant', ['tenant' => $tenant?->name ?? __('emails.tenant_feedback.unknown_tenant')])) + ->line(__('emails.ops.refund.package', ['package' => $package?->name ?? __('emails.package_limits.package_fallback')])) + ->line(__('emails.ops.refund.amount', ['amount' => $amount, 'currency' => '€'])) + ->line(__('emails.ops.refund.provider', ['provider' => $this->purchase->provider, 'id' => $this->purchase->provider_id ?? '—'])) + ->line($this->success ? __('emails.ops.refund.status_success') : __('emails.ops.refund.status_failed')); + + if ($this->reason) { + $mail->line(__('emails.ops.refund.reason', ['reason' => $this->reason])); + } + + if ($this->error) { + $mail->line(__('emails.ops.refund.error', ['error' => $this->error])); + } + + return $mail->line(__('emails.ops.refund.footer')); + } +} diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php index 229ebd4..8b52f91 100644 --- a/app/Services/Addons/EventAddonCheckoutService.php +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -26,6 +26,8 @@ class EventAddonCheckoutService { $addonKey = $payload['addon_key'] ?? null; $quantity = max(1, (int) ($payload['quantity'] ?? 1)); + $acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false); + $acceptedTerms = (bool) ($payload['accepted_terms'] ?? false); if (! $addonKey || ! $this->catalog->find($addonKey)) { throw ValidationException::withMessages([ @@ -60,6 +62,9 @@ class EventAddonCheckoutService 'addon_key' => $addonKey, 'addon_intent' => $addonIntent, 'quantity' => $quantity, + 'legal_version' => $this->resolveLegalVersion(), + 'accepted_terms' => $acceptedTerms ? '1' : '0', + 'accepted_waiver' => $acceptedWaiver ? '1' : '0', ]; $requestPayload = array_filter([ @@ -97,6 +102,11 @@ class EventAddonCheckoutService 'metadata' => array_merge($metadata, [ 'increments' => $increments, 'provider_payload' => $response, + 'consents' => [ + 'legal_version' => $metadata['legal_version'], + 'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null, + 'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null, + ], ]), 'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity, 'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity, @@ -109,4 +119,9 @@ class EventAddonCheckoutService 'id' => $checkoutId, ]; } + + protected function resolveLegalVersion(): string + { + return config('app.legal_version', now()->toDateString()); + } } diff --git a/app/Services/Addons/EventAddonWebhookService.php b/app/Services/Addons/EventAddonWebhookService.php index baf31db..b537483 100644 --- a/app/Services/Addons/EventAddonWebhookService.php +++ b/app/Services/Addons/EventAddonWebhookService.php @@ -5,6 +5,7 @@ namespace App\Services\Addons; use App\Models\EventPackage; use App\Models\EventPackageAddon; use App\Notifications\Addons\AddonPurchaseReceipt; +use App\Notifications\Ops\AddonPurchased; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -98,6 +99,11 @@ class EventAddonWebhookService if ($tenant) { Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email]) ->notify(new AddonPurchaseReceipt($addon)); + + $opsEmail = config('mail.ops_address'); + if ($opsEmail) { + Notification::route('mail', $opsEmail)->notify(new AddonPurchased($addon)); + } } }); diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index 7c0887d..d52cc5f 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -15,7 +15,9 @@ use Illuminate\Auth\Events\Registered; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Str; +use App\Notifications\Ops\PurchaseCreated; class CheckoutAssignmentService { @@ -48,6 +50,14 @@ class CheckoutAssignmentService } $metadata = $session->provider_metadata ?? []; + $consents = [ + 'accepted_terms_at' => optional($session->accepted_terms_at)->toIso8601String(), + 'accepted_privacy_at' => optional($session->accepted_privacy_at)->toIso8601String(), + 'accepted_withdrawal_notice_at' => optional($session->accepted_withdrawal_notice_at)->toIso8601String(), + 'digital_content_waiver_at' => optional($session->digital_content_waiver_at)->toIso8601String(), + 'legal_version' => $session->legal_version, + ]; + $consents = array_filter($consents); $providerReference = $options['provider_reference'] ?? $metadata['paddle_transaction_id'] ?? null @@ -72,7 +82,11 @@ class CheckoutAssignmentService 'price' => $session->amount_total, 'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event', 'purchased_at' => now(), - 'metadata' => $options['payload'] ?? null, + 'metadata' => array_filter([ + 'payload' => $options['payload'] ?? null, + 'checkout_session_id' => $session->id, + 'consents' => $consents ?: null, + ]), ] ); @@ -104,6 +118,11 @@ class CheckoutAssignmentService Mail::to($user) ->locale($mailLocale) ->queue(new PurchaseConfirmation($purchase)); + + $opsEmail = config('mail.ops_address'); + if ($opsEmail) { + Notification::route('mail', $opsEmail)->notify(new PurchaseCreated($purchase)); + } } AbandonedCheckout::query() diff --git a/app/Services/Paddle/PaddleTransactionService.php b/app/Services/Paddle/PaddleTransactionService.php index 305a439..50a7892 100644 --- a/app/Services/Paddle/PaddleTransactionService.php +++ b/app/Services/Paddle/PaddleTransactionService.php @@ -33,6 +33,21 @@ class PaddleTransactionService ]; } + /** + * Issue a refund for a Paddle transaction. + * + * @param array{reason?: string|null} $options + * @return array + */ + public function refund(string $transactionId, array $options = []): array + { + $payload = array_filter([ + 'reason' => $options['reason'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''); + + return $this->client->post("/transactions/{$transactionId}/refunds", $payload); + } + /** * @param array $transaction * @return array diff --git a/app/Support/Concerns/PresentsPackages.php b/app/Support/Concerns/PresentsPackages.php index 365a078..ae75ece 100644 --- a/app/Support/Concerns/PresentsPackages.php +++ b/app/Support/Concerns/PresentsPackages.php @@ -60,6 +60,7 @@ trait PresentsPackages 'max_events_per_year' => $package->max_events_per_year, 'watermark_allowed' => (bool) $package->watermark_allowed, 'branding_allowed' => (bool) $package->branding_allowed, + 'activates_immediately' => (bool) ($package->activates_immediately ?? true), ]; } diff --git a/config/mail.php b/config/mail.php index ad965a0..a0094a9 100644 --- a/config/mail.php +++ b/config/mail.php @@ -113,4 +113,16 @@ return [ 'name' => env('MAIL_FROM_NAME', 'Dein Fotospiel.App Team'), ], + /* + |-------------------------------------------------------------------------- + | Ops / SuperAdmin Notification Address + |-------------------------------------------------------------------------- + | + | Optional address for internal Ops/SuperAdmin notifications (purchases, + | add-ons, refunds). Leave null to disable Ops mail routing. + | + */ + + 'ops_address' => env('MAIL_OPS', 'info@fotospiel.app'), + ]; diff --git a/database/migrations/2025_12_06_234207_add_checkout_legal_consents.php b/database/migrations/2025_12_06_234207_add_checkout_legal_consents.php new file mode 100644 index 0000000..df1bec4 --- /dev/null +++ b/database/migrations/2025_12_06_234207_add_checkout_legal_consents.php @@ -0,0 +1,38 @@ +timestamp('accepted_terms_at')->nullable()->after('coupon_snapshot'); + $table->timestamp('accepted_privacy_at')->nullable()->after('accepted_terms_at'); + $table->timestamp('accepted_withdrawal_notice_at')->nullable()->after('accepted_privacy_at'); + $table->timestamp('digital_content_waiver_at')->nullable()->after('accepted_withdrawal_notice_at'); + $table->string('legal_version', 50)->nullable()->after('digital_content_waiver_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('checkout_sessions', function (Blueprint $table) { + $table->dropColumn([ + 'accepted_terms_at', + 'accepted_privacy_at', + 'accepted_withdrawal_notice_at', + 'digital_content_waiver_at', + 'legal_version', + ]); + }); + } +}; diff --git a/database/seeders/LegalPagesSeeder.php b/database/seeders/LegalPagesSeeder.php index 0efd206..df86e98 100644 --- a/database/seeders/LegalPagesSeeder.php +++ b/database/seeders/LegalPagesSeeder.php @@ -45,6 +45,16 @@ class LegalPagesSeeder extends Seeder 'en' => 'docs/content/legal/agb-en.md', ], ], + 'widerruf' => [ + 'title' => [ + 'de' => 'Widerrufsbelehrung', + 'en' => 'Rights of withdrawal', + ], + 'files' => [ + 'de' => 'docs/content/legal/widerrufsbelehrung-de.md', + 'en' => 'docs/content/legal/widerrufsbelehrung-en.md', + ], + ], ]; foreach ($pages as $slug => $config) { diff --git a/docs/content/legal/widerrufsbelehrung-de.md b/docs/content/legal/widerrufsbelehrung-de.md new file mode 100644 index 0000000..b3f7168 --- /dev/null +++ b/docs/content/legal/widerrufsbelehrung-de.md @@ -0,0 +1,97 @@ +# Widerrufsbelehrung für „Die Fotospiel App“ + +**Stand:** Oktober 2025 + +--- + +## 1. Geltungsbereich des Widerrufsrechts + +Diese Widerrufsbelehrung gilt für Verträge zwischen uns als Anbieter der „Fotospiel App“ und zugehöriger Online-Dienste (insbesondere Event-Packages, Fotospiel-Zugänge, Foto-Hosting, Online-Galerien) und **Verbrauchern** im Sinne von § 13 BGB. +**Unternehmer** im Sinne von § 14 BGB (z. B. Agenturen, Reseller, gewerbliche Veranstalter, Unternehmen) haben **kein gesetzliches Widerrufsrecht** nach dieser Belehrung. + +--- + +## 2. Widerrufsrecht + +Sie haben das Recht, binnen **vierzehn Tagen** ohne Angabe von Gründen diesen Vertrag zu widerrufen. + +Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. + +Um Ihr Widerrufsrecht auszuüben, müssen Sie uns + +> S.E.B. Fotografie +> [vollständige Anschrift wie im Impressum] +> E-Mail: [z. B. support@fotospiel.de] + +mittels einer **eindeutigen Erklärung** (z. B. ein mit der Post versandter Brief oder E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das unter Ziffer 6 beigefügte **Muster-Widerrufsformular** verwenden, das jedoch nicht vorgeschrieben ist. + +Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts **vor Ablauf der Widerrufsfrist** absenden. + +--- + +## 3. Folgen des Widerrufs + +Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen **alle Zahlungen**, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), **unverzüglich und spätestens binnen vierzehn Tagen** ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. + +Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben (z. B. über unseren Zahlungsdienstleister Paddle), es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung **Entgelte berechnet**. + +Bei Verträgen über **Dienstleistungen oder digitale Dienste**, die während der Widerrufsfrist bereits begonnen haben, gilt: + +- Haben Sie verlangt, dass die Dienstleistung bzw. der digitale Dienst (z. B. Freischaltung eines Event-Packages, Bereitstellung der Event-Galerie, Upload- und Hosting-Funktionen) **während der Widerrufsfrist** beginnen soll, so haben Sie uns einen **angemessenen Betrag** zu zahlen. +- Dieser Betrag entspricht dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum **Umfang der vertraglich vereinbarten Gesamtleistung**. + +--- + +## 4. Besondere Hinweise zu digitalen Dienstleistungen und Event-Packages + +Unsere Leistungen sind überwiegend **digitale Dienstleistungen** und **nicht auf einem körperlichen Datenträger** bereitgestellte digitale Inhalte, insbesondere: + +- Zugang zur Fotospiel-App für Event-Gäste +- Einrichtung und Verwaltung von Event-Galerien +- Upload, Speicherung und Bereitstellung von Fotos (Foto-Hosting) +- Zusatzfunktionen wie Aufgaben/Fotospiele, Analytics, Branding u. Ä. +- zeitlich befristete oder wiederkehrende **Packages** (Einmalkauf pro Event oder Abo) + +Ihr Widerrufsrecht kann bei diesen Verträgen **vorzeitig erlöschen**, wenn folgende Voraussetzungen erfüllt sind: + +1. Wir haben mit der **Ausführung des Vertrags** (z. B. Freischaltung Ihres Event-Packages, Aktivierung von Hosting-Leistungen) begonnen, **nachdem** +2. Sie **ausdrücklich zugestimmt** haben, dass wir vor Ablauf der Widerrufsfrist mit der Ausführung des Vertrags beginnen, und +3. Sie **bestätigt** haben, dass Sie **mit Beginn der Ausführung** Ihr Widerrufsrecht **verlieren**. + +Dies wird im Bestellprozess z. B. durch eine entsprechende **Checkbox und Hinweis-Text** dokumentiert. Ohne diese ausdrückliche Zustimmung und Kenntnisbestätigung erlischt Ihr Widerrufsrecht nicht vorzeitig. + +--- + +## 5. Widerruf bei Abonnements (wiederkehrende Leistungen) + +Bei wiederkehrenden Leistungen (z. B. jährliche Reseller-/Agentur-Packages oder Abos für Event-Kontingente) gilt: + +- Sie haben als Verbraucher ein **Widerrufsrecht von 14 Tagen** ab Vertragsabschluss. +- Widerrufen Sie, nachdem die Leistungen bereits begonnen haben (z. B. Event-Credits genutzt, Galerien aktiv), behalten wir uns vor, **Wertersatz** für den bereits erbrachten Zeitraum bzw. die bereits genutzten Leistungen zu verlangen. +- Nach Ablauf der Widerrufsfrist ist ein Widerruf nach diesen Regeln nicht mehr möglich; es gelten dann ausschließlich die vertraglichen und gesetzlichen **Kündigungsregelungen** (siehe AGB). + +--- + +## 6. Muster-Widerrufsformular + +> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) +> +> **An** +> S.E.B. Fotografie +> [vollständige Anschrift wie im Impressum] +> E-Mail: [z. B. support@fotospiel.de] +> +> **Hiermit widerrufe(n) ich/wir (*)** den von mir/uns (*) abgeschlossenen Vertrag über die Erbringung der folgenden Dienstleistung: +> +> – Bestellte digitale Dienstleistung / Event-Package / Abo (*): +> – Bestellt am (*): +> – Name des/der Verbraucher(s): +> – Anschrift des/der Verbraucher(s): +> – E-Mail-Adresse (mit der bestellt wurde): +> +> Datum: +> +> Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) +> +> (*) Unzutreffendes streichen. + diff --git a/docs/content/legal/widerrufsbelehrung-en.md b/docs/content/legal/widerrufsbelehrung-en.md new file mode 100644 index 0000000..5d13360 --- /dev/null +++ b/docs/content/legal/widerrufsbelehrung-en.md @@ -0,0 +1,97 @@ +# Right of Withdrawal for “The Fotospiel App” + +**Last updated:** October 2025 + +--- + +## 1. Scope of the Right of Withdrawal + +This withdrawal policy applies to contracts between us as the provider of “The Fotospiel App” and related online services (in particular event packages, Fotospiel access, photo hosting, online galleries) and **consumers** within the meaning of Art. 13 German Civil Code (BGB). +**Business customers** within the meaning of Art. 14 BGB (e.g. agencies, resellers, commercial organizers, companies) **do not have a statutory right of withdrawal** under this policy. + +--- + +## 2. Right of Withdrawal + +You have the right to withdraw from this contract within **fourteen days** without giving any reason. + +The withdrawal period is fourteen days from the day on which the contract is concluded. + +To exercise your right of withdrawal, you must inform us + +> S.E.B. Fotografie +> [full address as stated in the imprint] +> Email: [e.g. support@fotospiel.de] + +of your decision to withdraw from this contract by means of a **clear statement** (e.g. a letter sent by post or email). You may use the **model withdrawal form** set out in section 6, but this is not mandatory. + +To meet the withdrawal deadline, it is sufficient for you to send your communication concerning your exercise of the right of withdrawal **before the withdrawal period has expired**. + +--- + +## 3. Consequences of Withdrawal + +If you withdraw from this contract, we shall reimburse you for **all payments** we have received from you, including delivery costs (with the exception of any additional costs arising from the fact that you have chosen a type of delivery other than the least expensive standard delivery offered by us), **without undue delay and at the latest within fourteen days** from the day on which we receive notification of your withdrawal from this contract. + +For this reimbursement, we will use the same means of payment that you used for the original transaction (e.g. via our payment provider Paddle), unless we have expressly agreed otherwise with you; in no event will you be charged any **fees** as a result of such reimbursement. + +For contracts for **services or digital services** which have already started during the withdrawal period, the following applies: + +- If you have requested that the service or digital service (e.g. activation of an event package, provision of an event gallery, upload and hosting functions) **commence during the withdrawal period**, you must pay us an **appropriate amount**. +- This amount corresponds to the portion of the services already provided up to the time at which you inform us of the exercise of the right of withdrawal in relation to this contract, in comparison to the **full scope of the services** covered by the contract. + +--- + +## 4. Special Notes on Digital Services and Event Packages + +Our services mainly consist of **digital services** and **digital content not supplied on a tangible medium**, in particular: + +- Access to the Fotospiel App for event guests +- Creation and management of event galleries +- Upload, storage and provision of photos (photo hosting) +- Additional features such as tasks/photo games, analytics, branding, etc. +- time-limited or recurring **packages** (one-time purchase per event or subscriptions) + +Your right of withdrawal may **expire early** for these contracts if the following conditions are met: + +1. We have begun with the **performance of the contract** (e.g. activation of your event package, activation of hosting services) **after** +2. You have **explicitly consented** to us beginning performance of the contract before the end of the withdrawal period, and +3. You have **acknowledged** that you **lose your right of withdrawal** once performance of the contract has begun. + +This is documented in the checkout process, for example by means of a corresponding **checkbox and notice text**. Without this explicit consent and acknowledgement, your right of withdrawal does not expire early. + +--- + +## 5. Withdrawal for Subscriptions (Recurring Services) + +For recurring services (e.g. annual reseller/agency packages or subscriptions for event contingents), the following applies: + +- As a consumer, you have a **right of withdrawal of 14 days** from the conclusion of the contract. +- If you withdraw after services have already started (e.g. event credits have been used, galleries are active), we reserve the right to claim **compensation for the value** of the services already provided and/or used. +- After expiry of the withdrawal period, withdrawal under these rules is no longer possible; in this case, only the contractual and statutory **termination provisions** (see Terms & Conditions) apply. + +--- + +## 6. Model Withdrawal Form + +> (If you want to withdraw from the contract, please fill in this form and send it back.) +> +> **To** +> S.E.B. Fotografie +> [full address as stated in the imprint] +> Email: [e.g. support@fotospiel.de] +> +> **I/We (*) hereby withdraw from the contract concluded by me/us (*)** for the provision of the following service: +> +> – Ordered digital service / event package / subscription (*): +> – Ordered on (*): +> – Name of consumer(s): +> – Address of consumer(s): +> – Email address used for the order: +> +> Date: +> +> Signature of consumer(s) (only if this form is notified on paper) +> +> (*) Delete as appropriate. + diff --git a/docs/legal/widerrufsbelehrung-de.md b/docs/legal/widerrufsbelehrung-de.md new file mode 100644 index 0000000..1d71a75 --- /dev/null +++ b/docs/legal/widerrufsbelehrung-de.md @@ -0,0 +1,5 @@ +# Widerrufsbelehrung für „Die Fotospiel App“ + +*(Fallback-Datei; maßgeblich ist die in der Datenbank hinterlegte Version.)* + +Siehe aktuelle Version unter `/de/widerrufsbelehrung`. diff --git a/docs/legal/widerrufsbelehrung-en.md b/docs/legal/widerrufsbelehrung-en.md new file mode 100644 index 0000000..ed0c925 --- /dev/null +++ b/docs/legal/widerrufsbelehrung-en.md @@ -0,0 +1,5 @@ +# Right of Withdrawal for “The Fotospiel App” + +*(Fallback file; authoritative version lives in the database.)* + +See the current version at `/en/withdrawal`. diff --git a/docs/process/changes/2025-12-ops-mail-and-withdrawal.md b/docs/process/changes/2025-12-ops-mail-and-withdrawal.md new file mode 100644 index 0000000..1fe11e0 --- /dev/null +++ b/docs/process/changes/2025-12-ops-mail-and-withdrawal.md @@ -0,0 +1,4 @@ +# Ops-Mail Default & Widerrufsbelehrung Routing + +- `MAIL_OPS` neu in `config/mail.php` (Default: `info@fotospiel.app` in `.env.example`). Ops-Benachrichtigungen für Käufe/Add-ons/Refunds nutzen diese Adresse. +- Neue Legal-Routen für Widerrufsbelehrung (`/de/widerrufsbelehrung`, `/en/withdrawal`). Inhalte werden aus der `legal_pages` Tabelle geladen; Fallback-Markdowns liegen unter `docs/legal/widerrufsbelehrung-de/en.md`. Markdown wird wie bei anderen Legal-Seiten gerendert. diff --git a/public/lang/de/legal.json b/public/lang/de/legal.json index aa24c03..b7bcfd0 100644 --- a/public/lang/de/legal.json +++ b/public/lang/de/legal.json @@ -31,6 +31,7 @@ "and": "und", "stripe_privacy": "Stripe Datenschutz", "agb": "Allgemeine Geschäftsbedingungen", + "widerrufsbelehrung": "Widerrufsbelehrung", "effective_from": "Gültig seit {{date}}", "version": "Version {{version}}" } diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index 39ec060..237c15c 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -167,6 +167,7 @@ "detail_labels": { "photos": "Fotos", "guests": "Gäste", + "gaste": "Gäste", "tasks": "Aufgaben", "gallery": "Galerie", "branding": "Branding", @@ -480,6 +481,26 @@ "back": "Zurück", "next": "Weiter", "cancel": "Abbrechen", + "legal": { + "headline": "Rechtliches & Bestätigungen", + "summary_title": "Ihre Bestellung", + "package_label": "Ausgewähltes Paket", + "billing_type_one_time": "Einmalkauf (pro Event)", + "billing_type_subscription": "Abo (wiederkehrend)", + "legal_links_intro": "Details zur Belehrung:", + "link_terms": "AGB", + "link_privacy": "Datenschutzerklärung", + "link_cancellation": "Widerrufsbelehrung", + "checkbox_terms_label": "Ich habe die AGB, die Datenschutzerklärung und die Widerrufsbelehrung gelesen und akzeptiere sie.", + "checkbox_terms_error": "Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.", + "checkbox_digital_content_label": "Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.", + "checkbox_digital_content_error": "Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.", + "hint_subscription_withdrawal": "Bei Abonnements haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.", + "open_withdrawal": "Widerrufsbelehrung anzeigen", + "modal_description": "So informieren wir über das Widerrufsrecht. Der volle Text gilt für deinen Kauf.", + "modal_loading": "Widerrufsbelehrung wird geladen…", + "modal_error": "Widerrufsbelehrung konnte nicht geladen werden." + }, "package_step": { "title": "Paket wählen", "subtitle": "Auswahl und Vergleich", diff --git a/public/lang/en/legal.json b/public/lang/en/legal.json index 871a660..9f433f5 100644 --- a/public/lang/en/legal.json +++ b/public/lang/en/legal.json @@ -28,6 +28,7 @@ "data_security": "Data Security", "data_security_desc": "We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).", "agb": "Terms & Conditions", + "widerrufsbelehrung": "Right of Withdrawal", "effective_from": "Effective from {{date}}", "version": "Version {{version}}" } diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 8d19317..aa4dda4 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -473,6 +473,26 @@ "back": "Back", "next": "Next", "cancel": "Cancel", + "legal": { + "headline": "Legal & confirmations", + "summary_title": "Your order", + "package_label": "Selected package", + "billing_type_one_time": "One-time purchase (per event)", + "billing_type_subscription": "Subscription (recurring)", + "legal_links_intro": "Details on the withdrawal policy:", + "link_terms": "Terms & Conditions", + "link_privacy": "Privacy Policy", + "link_cancellation": "Right of Withdrawal", + "checkbox_terms_label": "I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.", + "checkbox_terms_error": "Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.", + "checkbox_digital_content_label": "I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.", + "checkbox_digital_content_error": "Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.", + "hint_subscription_withdrawal": "For subscriptions, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.", + "open_withdrawal": "View withdrawal policy", + "modal_description": "Below is the current withdrawal policy for your purchase.", + "modal_loading": "Loading withdrawal policy…", + "modal_error": "Could not load the withdrawal policy." + }, "package_step": { "title": "Select Package", "subtitle": "Selection and Comparison", diff --git a/resources/js/components/ui/scroll-area.tsx b/resources/js/components/ui/scroll-area.tsx new file mode 100644 index 0000000..ab68e8c --- /dev/null +++ b/resources/js/components/ui/scroll-area.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface ScrollAreaProps extends React.HTMLAttributes { + viewportClassName?: string; +} + +export const ScrollArea = React.forwardRef( + ({ className, children, viewportClassName, ...props }, ref) => ( +
+
+ {children} +
+
+ ) +); + +ScrollArea.displayName = 'ScrollArea'; diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index c893c07..d98ea4a 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -185,6 +185,8 @@ const [canUpload, setCanUpload] = useState(true); const canvasRef = useRef(null); const fileInputRef = useRef(null); const liveRegionRef = useRef(null); + const cameraViewportRef = useRef(null); + const cameraShellRef = useRef(null); const streamRef = useRef(null); const countdownTimerRef = useRef(null); @@ -481,6 +483,25 @@ const [canUpload, setCanUpload] = useState(true); setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred })); }, []); + const handleToggleImmersive = useCallback(async () => { + setImmersiveMode((prev) => !prev); + const shell = cameraShellRef.current; + if (!shell) return; + const prefersReducedMotion = typeof window !== 'undefined' + ? window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches + : false; + if (prefersReducedMotion) return; + try { + if (!document.fullscreenElement) { + await shell.requestFullscreen?.(); + } else { + await document.exitFullscreen?.(); + } + } catch (error) { + console.warn('Fullscreen toggle failed', error); + } + }, []); + const triggerConfetti = useCallback(async () => { if (typeof window === 'undefined') return; const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; @@ -883,14 +904,14 @@ const [canUpload, setCanUpload] = useState(true); ) : null; const heroOverlay = !task && showHeroOverlay && mode !== 'uploading' ? ( -
-
-
-

Bereit für dein Foto?

-

Teile den Moment mit allen Gästen.

-

Zieh dir eine Mission oder starte direkt mit der Kamera.

+
+
+
+

Bereit für dein Foto?

+

Teile den Moment mit allen Gästen.

+

Zieh eine Mission oder starte direkt.

- + Live
@@ -898,7 +919,10 @@ const [canUpload, setCanUpload] = useState(true); @@ -906,11 +930,14 @@ const [canUpload, setCanUpload] = useState(true); size="sm" variant="secondary" className="rounded-full border border-white/30 bg-white/10 text-white" - onClick={() => navigate(tasksUrl)} + onClick={() => { + setShowHeroOverlay(false); + navigate(tasksUrl); + }} > Stimmung wählen - + Mini-Mission: Fang ein Lachen ein @@ -1069,14 +1096,26 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ return renderWithDialog( <> -
+
} + className="relative flex min-h-screen flex-col gap-4 pb-[calc(env(safe-area-inset-bottom,0px)+12px)] pt-3" + style={bodyFont ? { fontFamily: bodyFont } : undefined} + > {taskFloatingCard} {heroOverlay}
-
+
- -

- {t('checkout.payment_step.guided_cta_hint')} -

+
+
+
+ { + setAcceptedTerms(Boolean(checked)); + if (consentError) { + setConsentError(null); + } + }} + className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]" + /> +
+ +

+ {t('checkout.legal.legal_links_intro')}{' '} + +

+
+
+ + {requiresImmediateWaiver && ( +
+ { + setAcceptedWaiver(Boolean(checked)); + if (consentError) { + setConsentError(null); + } + }} + className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]" + /> +
+ +

+ {t('checkout.legal.hint_subscription_withdrawal')} +

+
+
+ )} + + {consentError && ( +
+ + {consentError} +
+ )} +
+ +
+ +

+ {t('checkout.payment_step.guided_cta_hint')} +

+
+
@@ -632,7 +754,7 @@ export const PaymentStep: React.FC = () => {

@@ -664,6 +786,37 @@ export const PaymentStep: React.FC = () => {

+ + + + + {withdrawalTitle || t('checkout.legal.link_cancellation')} + {t('checkout.legal.modal_description')} + +
+ {withdrawalLoading && ( +
+ + {t('checkout.legal.modal_loading')} +
+ )} + {withdrawalError && ( +
+ + {withdrawalError} +
+ )} + {!withdrawalLoading && !withdrawalError && withdrawalHtml && ( + +
+ + )} +
+ +
); }; diff --git a/resources/js/pages/marketing/checkout/types.ts b/resources/js/pages/marketing/checkout/types.ts index deb05db..67d757b 100644 --- a/resources/js/pages/marketing/checkout/types.ts +++ b/resources/js/pages/marketing/checkout/types.ts @@ -26,6 +26,7 @@ export interface CheckoutPackage { limits?: Record; paddle_price_id?: string | null; paddle_product_id?: string | null; + activates_immediately?: boolean; [key: string]: unknown; } diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index 2fa0f63..56a028b 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -153,4 +153,48 @@ return [ 'open' => 'Feedback im Super Admin öffnen', 'received_at' => 'Eingegangen: :date', ], + + 'refund' => [ + 'subject' => 'Rückerstattung für :package', + 'greeting' => 'Hallo :name,', + 'body' => 'Wir haben eine Rückerstattung eingeleitet. Betrag: :amount :currency. Zahlungs-ID: :provider_id.', + 'reason' => 'Grund: :reason', + 'footer' => 'Die Rückerstattung wird vom Zahlungsanbieter verarbeitet und kann je nach Bank einige Tage dauern.', + ], + + 'ops' => [ + 'purchase' => [ + 'subject' => 'Neuer Kauf: :package', + 'greeting' => 'Hallo Ops-Team,', + 'tenant' => 'Tenant: :tenant', + 'package' => 'Paket: :package', + 'amount' => 'Betrag: :amount :currency', + 'provider' => 'Provider: :provider (ID: :id)', + 'consents' => 'Consents – Version: :legal, Terms: :terms, Waiver: :waiver', + 'footer' => 'Bitte prüfen und ggf. verbuchen.', + ], + 'addon' => [ + 'subject' => 'Add-on gekauft: :addon', + 'greeting' => 'Hallo Ops-Team,', + 'tenant' => 'Tenant: :tenant', + 'event' => 'Event: :event', + 'addon' => 'Add-on: :addon (Menge: :quantity)', + 'amount' => 'Betrag: :amount :currency', + 'provider' => 'Checkout: :checkout, Transaction: :transaction', + 'footer' => 'Add-on ist abgeschlossen und angewendet.', + ], + 'refund' => [ + 'subject' => 'Refund verarbeitet: :package', + 'greeting' => 'Hallo Ops-Team,', + 'tenant' => 'Tenant: :tenant', + 'package' => 'Paket: :package', + 'amount' => 'Betrag: :amount :currency', + 'provider' => 'Provider: :provider (ID: :id)', + 'status_success' => 'Status: Erfolgreich', + 'status_failed' => 'Status: Fehlgeschlagen', + 'reason' => 'Grund: :reason', + 'error' => 'Fehler: :error', + 'footer' => 'Bitte prüfen und ggf. manuell nachfassen.', + ], + ], ]; diff --git a/resources/lang/de/legal.php b/resources/lang/de/legal.php index c13bd61..2ddc6e6 100644 --- a/resources/lang/de/legal.php +++ b/resources/lang/de/legal.php @@ -35,4 +35,5 @@ return [ 'version' => 'Version :version', 'and' => 'und', 'stripe_privacy' => 'Stripe Datenschutz', + 'widerrufsbelehrung' => 'Widerrufsbelehrung', ]; diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index 055df77..e227cd2 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -100,6 +100,22 @@ return [ 'company' => 'S.E.B. Fotografie', 'rights_reserved' => 'Alle Rechte vorbehalten', ], + 'checkout' => [ + 'headline' => 'Checkout & Bestellübersicht', + 'summary_title' => 'Ihre Bestellung', + 'package_label' => 'Ausgewähltes Package', + 'billing_type_one_time' => 'Einmalkauf (pro Event)', + 'billing_type_subscription' => 'Abo (wiederkehrend)', + 'legal_links_intro' => 'Mit Abschluss des Kaufs akzeptieren Sie unsere', + 'link_terms' => 'AGB', + 'link_privacy' => 'Datenschutzerklärung', + 'link_cancellation' => 'Widerrufsbelehrung', + 'checkbox_terms_label' => 'Ich habe die :terms, die :privacy und die :cancellation gelesen und akzeptiere sie.', + 'checkbox_terms_error' => 'Bitte bestätigen Sie, dass Sie AGB, Datenschutzerklärung und Widerrufsbelehrung gelesen haben.', + 'checkbox_digital_content_label' => 'Ich verlange ausdrücklich, dass Sie vor Ablauf der Widerrufsfrist mit der Ausführung der digitalen Dienstleistungen (Freischaltung meines Event-Packages inkl. Galerie und Hosting) beginnen. Mir ist bekannt, dass ich bei vollständiger Vertragserfüllung mein Widerrufsrecht verliere.', + 'checkbox_digital_content_error' => 'Bitte bestätigen Sie, dass Sie dem sofortigen Beginn der digitalen Dienstleistung und dem damit verbundenen vorzeitigen Erlöschen des Widerrufsrechts zustimmen.', + 'hint_subscription_withdrawal' => 'Bei Abonnements haben Verbraucher ein 14-tägiges Widerrufsrecht ab Vertragsschluss. Im Falle eines Widerrufs nach Leistungsbeginn behalten wir uns angemessenen Wertersatz für bereits erbrachte Leistungen vor.', + ], 'legal' => [ 'imprint' => 'Impressum', 'privacy' => 'Datenschutz', diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index b1873fb..97f00d2 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -153,4 +153,48 @@ return [ 'open' => 'Open feedback in Super Admin', 'received_at' => 'Received: :date', ], + + 'refund' => [ + 'subject' => 'Refund for :package', + 'greeting' => 'Hi :name,', + 'body' => 'We have initiated a refund. Amount: :amount :currency. Payment ID: :provider_id.', + 'reason' => 'Reason: :reason', + 'footer' => 'The refund is processed by the payment provider and may take a few days depending on your bank.', + ], + + 'ops' => [ + 'purchase' => [ + 'subject' => 'New purchase: :package', + 'greeting' => 'Hello Ops team,', + 'tenant' => 'Tenant: :tenant', + 'package' => 'Package: :package', + 'amount' => 'Amount: :amount :currency', + 'provider' => 'Provider: :provider (ID: :id)', + 'consents' => 'Consents – Version: :legal, Terms: :terms, Waiver: :waiver', + 'footer' => 'Please review and reconcile.', + ], + 'addon' => [ + 'subject' => 'Add-on purchased: :addon', + 'greeting' => 'Hello Ops team,', + 'tenant' => 'Tenant: :tenant', + 'event' => 'Event: :event', + 'addon' => 'Add-on: :addon (Quantity: :quantity)', + 'amount' => 'Amount: :amount :currency', + 'provider' => 'Checkout: :checkout, Transaction: :transaction', + 'footer' => 'Add-on is completed and applied.', + ], + 'refund' => [ + 'subject' => 'Refund processed: :package', + 'greeting' => 'Hello Ops team,', + 'tenant' => 'Tenant: :tenant', + 'package' => 'Package: :package', + 'amount' => 'Amount: :amount :currency', + 'provider' => 'Provider: :provider (ID: :id)', + 'status_success' => 'Status: Success', + 'status_failed' => 'Status: Failed', + 'reason' => 'Reason: :reason', + 'error' => 'Error: :error', + 'footer' => 'Please review and follow up if needed.', + ], + ], ]; diff --git a/resources/lang/en/legal.php b/resources/lang/en/legal.php index e72cbf1..671fd59 100644 --- a/resources/lang/en/legal.php +++ b/resources/lang/en/legal.php @@ -30,6 +30,7 @@ return [ 'data_security' => 'Data Security', 'data_security_desc' => 'We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).', 'agb' => 'Terms & Conditions', + 'widerrufsbelehrung' => 'Right of Withdrawal', 'headline' => 'Legal', 'effective_from' => 'Effective from :date', 'version' => 'Version :version', diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index b75e14c..067a9bd 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -100,6 +100,22 @@ return [ 'company' => 'S.E.B. Fotografie', 'rights_reserved' => 'All rights reserved', ], + 'checkout' => [ + 'headline' => 'Checkout & Order Summary', + 'summary_title' => 'Your order', + 'package_label' => 'Selected package', + 'billing_type_one_time' => 'One-time purchase (per event)', + 'billing_type_subscription' => 'Subscription (recurring)', + 'legal_links_intro' => 'By completing your order you accept our', + 'link_terms' => 'Terms & Conditions', + 'link_privacy' => 'Privacy Policy', + 'link_cancellation' => 'Right of Withdrawal', + 'checkbox_terms_label' => 'I have read and accept the :terms, :privacy and :cancellation.', + 'checkbox_terms_error' => 'Please confirm that you have read and accepted the Terms, Privacy Policy and Right of Withdrawal.', + 'checkbox_digital_content_label' => 'I expressly request that you begin providing the digital services (activation of my event package including gallery and hosting) before the withdrawal period has expired. I understand that I lose my right of withdrawal once the contract has been fully performed.', + 'checkbox_digital_content_error' => 'Please confirm that you agree to the immediate start of the digital service and the related early expiry of the right of withdrawal.', + 'hint_subscription_withdrawal' => 'For subscriptions, consumers have a 14-day right of withdrawal from the conclusion of the contract. In case of withdrawal after the start of the service, we reserve the right to claim appropriate compensation for the value of services already provided.', + ], 'legal' => [ 'imprint' => 'Imprint', 'privacy' => 'Privacy', diff --git a/resources/views/partials/footer.blade.php b/resources/views/partials/footer.blade.php index dd0d10a..e60bdb7 100644 --- a/resources/views/partials/footer.blade.php +++ b/resources/views/partials/footer.blade.php @@ -7,6 +7,8 @@ {{ __('legal.datenschutz') }} {{ __('legal.agb') }} + + {{ __('legal.widerrufsbelehrung') }}
diff --git a/routes/web.php b/routes/web.php index dd317b3..dbe3f68 100644 --- a/routes/web.php +++ b/routes/web.php @@ -132,6 +132,13 @@ Route::prefix('{locale}') ->where('locale', 'en') ->defaults('slug', 'agb') ->name('terms'); + Route::get('/widerrufsbelehrung', [LegalPageController::class, 'show']) + ->defaults('slug', 'widerrufsbelehrung') + ->name('widerrufsbelehrung'); + Route::get('/withdrawal', [LegalPageController::class, 'show']) + ->where('locale', 'en') + ->defaults('slug', 'widerrufsbelehrung') + ->name('withdrawal'); Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages']) ->name('buy.packages') diff --git a/tests/Feature/Tenant/EventAddonCheckoutTest.php b/tests/Feature/Tenant/EventAddonCheckoutTest.php index 51d6860..1feffbe 100644 --- a/tests/Feature/Tenant/EventAddonCheckoutTest.php +++ b/tests/Feature/Tenant/EventAddonCheckoutTest.php @@ -62,6 +62,8 @@ class EventAddonCheckoutTest extends TenantTestCase $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [ 'addon_key' => 'extra_photos_small', 'quantity' => 2, + 'accepted_terms' => true, + 'accepted_waiver' => true, ]); $response->assertOk();