feat: Widerrufsrecht-Bestätigung beim Upgrade (Free → Pro)

- Migration: withdrawal_waivers (user, plan, billing, amount, IP,
  user_agent, confirmed_at, pdf_path für spätere PDF-Generierung)
- Model: WithdrawalWaiver mit User/Plan-Relation
- Checkout/Index: rightAcknowledged + waiverConfirmed Properties;
  Validierung vor Checkout; Waiver-Record wird vor Zahlung gespeichert
- View: Amber-Box mit Hinweistext + 2 Checkboxen; CTA-Button disabled
  solange nicht beide bestätigt; nur bei paid Plänen sichtbar
- Übersetzungen: waiver_info, waiver_right_acknowledged,
  waiver_confirmed, waiver_required (DE + EN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-04-20 19:05:41 +02:00
parent 2c81e2533d
commit b863b67979
5 changed files with 152 additions and 1 deletions

View File

@ -6,6 +6,7 @@ use App\Enums\SubscriptionStatus;
use App\Models\Feature;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\WithdrawalWaiver;
use App\Services\StripeService;
use Livewire\Attributes\Computed;
use Livewire\Component;
@ -15,6 +16,9 @@ class Index extends Component
public Plan $plan;
public string $billing = 'monthly';
public bool $rightAcknowledged = false;
public bool $waiverConfirmed = false;
public function mount(string $planId, string $billing = 'monthly'): void
{
$this->plan = Plan::public()->where('active', true)->findOrFail($planId);
@ -53,13 +57,34 @@ class Index extends Component
$stripe = app(StripeService::class);
try {
// ── Free Plan: Stripe-Abo kündigen ───────────────────────────
// ── Free Plan: Stripe-Abo kündigen (kein Widerrufsrecht nötig)
if ($this->plan->isFree()) {
$stripe->cancelUserSubscription($user);
$this->redirect(route('subscription.index'), navigate: false);
return;
}
// ── Widerrufsrecht-Bestätigung prüfen ─────────────────────────
if (!$this->rightAcknowledged || !$this->waiverConfirmed) {
$this->dispatch('notify', ['type' => 'error', 'message' => t('checkout.waiver_required')]);
return;
}
// ── Widerrufsverzicht speichern ────────────────────────────────
WithdrawalWaiver::create([
'user_id' => $user->id,
'plan_id' => $this->plan->id,
'plan_name' => $this->plan->name,
'billing' => $this->billing,
'amount_cents' => $this->activePrice,
'checkout_type' => $this->checkoutType,
'right_acknowledged' => true,
'waiver_confirmed' => true,
'confirmed_at' => now(),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
// ── Upgrade / Downgrade: bestehendes Abo in-place updaten ─────
$existingSub = $this->activeStripeSub;
if ($existingSub) {

View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WithdrawalWaiver extends Model
{
use HasUuids;
protected $fillable = [
'user_id',
'plan_id',
'plan_name',
'billing',
'amount_cents',
'checkout_type',
'right_acknowledged',
'waiver_confirmed',
'confirmed_at',
'ip_address',
'user_agent',
'pdf_path',
];
protected $casts = [
'right_acknowledged' => 'boolean',
'waiver_confirmed' => 'boolean',
'confirmed_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('withdrawal_waivers', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('plan_id')->nullable()->constrained()->nullOnDelete();
$table->string('plan_name');
$table->string('billing', 20);
$table->unsignedInteger('amount_cents');
$table->string('checkout_type', 20);
$table->boolean('right_acknowledged')->default(false);
$table->boolean('waiver_confirmed')->default(false);
$table->timestamp('confirmed_at');
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->string('pdf_path')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('withdrawal_waivers');
}
};

View File

@ -758,6 +758,22 @@ class TranslationSeeder extends Seeder
'checkout.redirect_stripe' => ['de' => 'Du wirst sicher zu Stripe weitergeleitet.', 'en' => 'You will be securely redirected to Stripe.'],
'checkout.no_checkout' => ['de' => 'Kein Checkout nötig Stripe verarbeitet die Änderung direkt.', 'en' => 'No checkout needed Stripe processes the change directly.'],
'checkout.cancel_effective' => ['de' => 'Kündigung wird sofort wirksam.', 'en' => 'Cancellation takes effect immediately.'],
'checkout.waiver_info' => [
'de' => 'Hinweis zum Widerrufsrecht: Du hast das Recht, diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen zu widerrufen. Da wir die Dienstleistung sofort bereitstellen, erlischt dein Widerrufsrecht mit Beginn der Leistungserbringung sofern du ausdrücklich zustimmst.',
'en' => 'Right of withdrawal notice: You have the right to withdraw from this contract within 14 days without giving any reason. Since we provide the service immediately, your right of withdrawal expires upon commencement of the service provided you expressly agree.',
],
'checkout.waiver_right_acknowledged' => [
'de' => 'Ich bestätige, dass ich über mein 14-tägiges Widerrufsrecht informiert wurde.',
'en' => 'I confirm that I have been informed about my 14-day right of withdrawal.',
],
'checkout.waiver_confirmed' => [
'de' => 'Ich verlange ausdrücklich die sofortige Ausführung des Vertrags und bestätige, dass ich mit Beginn der Leistungserbringung mein Widerrufsrecht verliere.',
'en' => 'I expressly request the immediate execution of the contract and acknowledge that I will lose my right of withdrawal upon commencement of the service.',
],
'checkout.waiver_required' => [
'de' => 'Bitte bestätige beide Checkboxen zum Widerrufsrecht.',
'en' => 'Please confirm both checkboxes regarding the right of withdrawal.',
],
// ── Checkout Success ─────────────────────
'checkout.success.processing' => ['de' => 'Zahlung wird verarbeitet…', 'en' => 'Processing payment…'],

View File

@ -256,6 +256,38 @@
</div>
@endif
{{-- Widerrufsrecht-Checkboxen (nur bei paid plans) --}}
@if($this->checkoutType !== 'cancel')
<div class="space-y-3 border border-amber-100 bg-amber-50 rounded-xl p-4">
<div class="flex items-start gap-2.5">
<x-heroicon-o-information-circle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5"/>
<p class="text-[11px] text-amber-700 font-medium leading-relaxed">
{{ t('checkout.waiver_info') }}
</p>
</div>
<label class="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
wire:model="rightAcknowledged"
class="mt-0.5 rounded border-amber-300 text-amber-500 focus:ring-amber-400 shrink-0"
/>
<span class="text-[11px] text-amber-800 leading-relaxed">
{{ t('checkout.waiver_right_acknowledged') }}
</span>
</label>
<label class="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
wire:model="waiverConfirmed"
class="mt-0.5 rounded border-amber-300 text-amber-500 focus:ring-amber-400 shrink-0"
/>
<span class="text-[11px] text-amber-800 leading-relaxed">
{{ t('checkout.waiver_confirmed') }}
</span>
</label>
</div>
@endif
{{-- CTA --}}
@if($this->checkoutType === 'cancel')
<button
@ -278,6 +310,7 @@
<button
wire:click="startCheckout"
wire:loading.attr="disabled"
@if(!$rightAcknowledged || !$waiverConfirmed) disabled @endif
class="w-full flex items-center justify-center gap-2 px-5 py-3.5 rounded-xl bg-indigo-600 text-white font-semibold hover:bg-indigo-700 active:scale-95 transition-all shadow-sm shadow-indigo-200 disabled:opacity-60">
<span wire:loading.remove wire:target="startCheckout">
<x-heroicon-o-arrow-path class="w-4 h-4 inline-block mr-1 opacity-70"/>
@ -295,6 +328,7 @@
<button
wire:click="startCheckout"
wire:loading.attr="disabled"
@if(!$rightAcknowledged || !$waiverConfirmed) disabled @endif
class="w-full flex items-center justify-center gap-2 px-5 py-3.5 rounded-xl bg-indigo-600 text-white font-semibold hover:bg-indigo-700 active:scale-95 transition-all shadow-sm shadow-indigo-200 disabled:opacity-60">
<span wire:loading.remove wire:target="startCheckout">
<x-heroicon-o-lock-closed class="w-4 h-4 inline-block mr-1 opacity-70"/>