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
parent
2c81e2533d
commit
b863b67979
|
|
@ -6,6 +6,7 @@ use App\Enums\SubscriptionStatus;
|
||||||
use App\Models\Feature;
|
use App\Models\Feature;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
|
use App\Models\WithdrawalWaiver;
|
||||||
use App\Services\StripeService;
|
use App\Services\StripeService;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
@ -15,6 +16,9 @@ class Index extends Component
|
||||||
public Plan $plan;
|
public Plan $plan;
|
||||||
public string $billing = 'monthly';
|
public string $billing = 'monthly';
|
||||||
|
|
||||||
|
public bool $rightAcknowledged = false;
|
||||||
|
public bool $waiverConfirmed = false;
|
||||||
|
|
||||||
public function mount(string $planId, string $billing = 'monthly'): void
|
public function mount(string $planId, string $billing = 'monthly'): void
|
||||||
{
|
{
|
||||||
$this->plan = Plan::public()->where('active', true)->findOrFail($planId);
|
$this->plan = Plan::public()->where('active', true)->findOrFail($planId);
|
||||||
|
|
@ -53,13 +57,34 @@ class Index extends Component
|
||||||
$stripe = app(StripeService::class);
|
$stripe = app(StripeService::class);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── Free Plan: Stripe-Abo kündigen ────────────────────────────
|
// ── Free Plan: Stripe-Abo kündigen (kein Widerrufsrecht nötig) ─
|
||||||
if ($this->plan->isFree()) {
|
if ($this->plan->isFree()) {
|
||||||
$stripe->cancelUserSubscription($user);
|
$stripe->cancelUserSubscription($user);
|
||||||
$this->redirect(route('subscription.index'), navigate: false);
|
$this->redirect(route('subscription.index'), navigate: false);
|
||||||
return;
|
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 ─────
|
// ── Upgrade / Downgrade: bestehendes Abo in-place updaten ─────
|
||||||
$existingSub = $this->activeStripeSub;
|
$existingSub = $this->activeStripeSub;
|
||||||
if ($existingSub) {
|
if ($existingSub) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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.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.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.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 ─────────────────────
|
||||||
'checkout.success.processing' => ['de' => 'Zahlung wird verarbeitet…', 'en' => 'Processing payment…'],
|
'checkout.success.processing' => ['de' => 'Zahlung wird verarbeitet…', 'en' => 'Processing payment…'],
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,38 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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 --}}
|
{{-- CTA --}}
|
||||||
@if($this->checkoutType === 'cancel')
|
@if($this->checkoutType === 'cancel')
|
||||||
<button
|
<button
|
||||||
|
|
@ -278,6 +310,7 @@
|
||||||
<button
|
<button
|
||||||
wire:click="startCheckout"
|
wire:click="startCheckout"
|
||||||
wire:loading.attr="disabled"
|
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">
|
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">
|
<span wire:loading.remove wire:target="startCheckout">
|
||||||
<x-heroicon-o-arrow-path class="w-4 h-4 inline-block mr-1 opacity-70"/>
|
<x-heroicon-o-arrow-path class="w-4 h-4 inline-block mr-1 opacity-70"/>
|
||||||
|
|
@ -295,6 +328,7 @@
|
||||||
<button
|
<button
|
||||||
wire:click="startCheckout"
|
wire:click="startCheckout"
|
||||||
wire:loading.attr="disabled"
|
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">
|
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">
|
<span wire:loading.remove wire:target="startCheckout">
|
||||||
<x-heroicon-o-lock-closed class="w-4 h-4 inline-block mr-1 opacity-70"/>
|
<x-heroicon-o-lock-closed class="w-4 h-4 inline-block mr-1 opacity-70"/>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue