Updated checkout to wait for backend confirmation before advancing, added a “Processing payment…” state with retry/ refresh fallback, and now use Paddle totals/currency for purchase records + confirmation emails (with new email translations).
This commit is contained in:
@@ -2,21 +2,27 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Checkout\CheckoutFreeActivationRequest;
|
||||
use App\Http\Requests\Checkout\CheckoutLoginRequest;
|
||||
use App\Http\Requests\Checkout\CheckoutRegisterRequest;
|
||||
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||
use App\Mail\Welcome;
|
||||
use App\Models\AbandonedCheckout;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CheckoutController extends Controller
|
||||
@@ -61,35 +67,15 @@ class CheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function register(CheckoutRegisterRequest $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|email|max:255|unique:users,email',
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'address' => 'required|string|max:500',
|
||||
'phone' => 'required|string|max:255',
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'terms' => 'required|accepted',
|
||||
'privacy_consent' => 'required|accepted',
|
||||
'locale' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
$validated = $validator->validated();
|
||||
$user = DB::transaction(function () use ($request, $package, $validated) {
|
||||
$validated = $request->validated();
|
||||
$package = Package::findOrFail($validated['package_id']);
|
||||
$user = DB::transaction(function () use ($package, $validated) {
|
||||
|
||||
// User erstellen
|
||||
$user = User::create([
|
||||
'email' => $request->email,
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
@@ -97,7 +83,7 @@ class CheckoutController extends Controller
|
||||
'address' => $validated['address'],
|
||||
'phone' => $validated['phone'],
|
||||
'preferred_locale' => $validated['locale'] ?? null,
|
||||
'password' => Hash::make($request->password),
|
||||
'password' => Hash::make($validated['password']),
|
||||
'pending_purchase' => true,
|
||||
]);
|
||||
|
||||
@@ -171,28 +157,21 @@ class CheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
public function login(CheckoutLoginRequest $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'identifier' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
'remember' => 'boolean',
|
||||
'locale' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
$validated = $request->validated();
|
||||
$packageId = $validated['package_id'] ?? $request->session()->get('selected_package_id');
|
||||
if ($packageId) {
|
||||
$request->session()->put('selected_package_id', $packageId);
|
||||
}
|
||||
|
||||
$packageId = $request->session()->get('selected_package_id');
|
||||
|
||||
// Custom Auth für Identifier (E-Mail oder Username)
|
||||
$identifier = $request->identifier;
|
||||
$identifier = $validated['identifier'];
|
||||
$user = User::where('email', $identifier)
|
||||
->orWhere('username', $identifier)
|
||||
->first();
|
||||
|
||||
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||
if (! $user || ! Hash::check($validated['password'], $user->password)) {
|
||||
return response()->json([
|
||||
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']],
|
||||
], 422);
|
||||
@@ -220,6 +199,74 @@ class CheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function activateFree(
|
||||
CheckoutFreeActivationRequest $request,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
): JsonResponse {
|
||||
$validated = $request->validated();
|
||||
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail($validated['package_id']);
|
||||
|
||||
if ($package->price > 0) {
|
||||
return response()->json([
|
||||
'message' => 'Package is not eligible for free activation.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$requiresWaiver = (bool) ($package->activates_immediately ?? true);
|
||||
|
||||
if ($requiresWaiver && ! $request->boolean('accepted_waiver')) {
|
||||
return response()->json([
|
||||
'errors' => [
|
||||
'accepted_waiver' => ['Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$session = $sessions->createOrResume($user, $package, [
|
||||
'tenant' => $user->tenant,
|
||||
'locale' => $validated['locale'] ?? null,
|
||||
]);
|
||||
|
||||
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
|
||||
|
||||
$now = now();
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $now,
|
||||
'accepted_privacy_at' => $now,
|
||||
'accepted_withdrawal_notice_at' => $now,
|
||||
'digital_content_waiver_at' => $requiresWaiver && $request->boolean('accepted_waiver') ? $now : null,
|
||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||
])->save();
|
||||
|
||||
$assignment->finalise($session, [
|
||||
'provider' => CheckoutSession::PROVIDER_FREE,
|
||||
]);
|
||||
|
||||
$sessions->markCompleted($session, $now);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'completed',
|
||||
'checkout_session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sessionStatus(
|
||||
CheckoutSessionStatusRequest $request,
|
||||
CheckoutSession $session,
|
||||
): JsonResponse {
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function trackAbandonedCheckout(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -21,17 +21,9 @@ class PaddleCheckoutController extends Controller
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): JsonResponse
|
||||
public function create(PaddleCheckoutRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
'accepted_waiver' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
$data = $request->validated();
|
||||
|
||||
$user = Auth::user();
|
||||
$tenant = $user?->tenant;
|
||||
@@ -89,6 +81,7 @@ class PaddleCheckoutController extends Controller
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'checkout_session_id' => $session->id,
|
||||
'mode' => 'inline',
|
||||
'items' => [
|
||||
[
|
||||
@@ -133,7 +126,9 @@ class PaddleCheckoutController extends Controller
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return response()->json($checkout);
|
||||
return response()->json(array_merge($checkout, [
|
||||
'checkout_session_id' => $session->id,
|
||||
]));
|
||||
}
|
||||
|
||||
protected function resolveLegalVersion(): string
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Services\Checkout\CheckoutWebhookService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TestCheckoutController extends Controller
|
||||
{
|
||||
@@ -89,7 +88,7 @@ class TestCheckoutController extends Controller
|
||||
'data' => array_filter([
|
||||
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
|
||||
'status' => $validated['status'] ?? 'completed',
|
||||
'metadata' => $metadata,
|
||||
'custom_data' => $metadata,
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
]),
|
||||
];
|
||||
|
||||
42
app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php
Normal file
42
app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckoutFreeActivationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
'accepted_waiver' => ['nullable', 'boolean'],
|
||||
'locale' => ['nullable', 'string', 'max:10'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
'accepted_terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Checkout/CheckoutLoginRequest.php
Normal file
44
app/Http/Requests/Checkout/CheckoutLoginRequest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckoutLoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'identifier' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
'remember' => ['nullable', 'boolean'],
|
||||
'locale' => ['nullable', 'string', 'max:10'],
|
||||
'package_id' => ['nullable', 'exists:packages,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'identifier.required' => 'Bitte gib deine E-Mail-Adresse oder deinen Benutzernamen an.',
|
||||
'password.required' => 'Bitte gib dein Passwort an.',
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Http/Requests/Checkout/CheckoutRegisterRequest.php
Normal file
54
app/Http/Requests/Checkout/CheckoutRegisterRequest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class CheckoutRegisterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users,username'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
'address' => ['required', 'string', 'max:500'],
|
||||
'phone' => ['required', 'string', 'max:255'],
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'terms' => ['required', 'accepted'],
|
||||
'privacy_consent' => ['required', 'accepted'],
|
||||
'locale' => ['nullable', 'string', 'max:10'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.unique' => 'Diese E-Mail-Adresse wird bereits verwendet.',
|
||||
'username.unique' => 'Dieser Benutzername ist bereits vergeben.',
|
||||
'password.confirmed' => 'Die Passwortbestätigung stimmt nicht überein.',
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
'terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
||||
'privacy_consent.accepted' => 'Bitte akzeptiere die Datenschutzerklärung.',
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/Checkout/CheckoutSessionStatusRequest.php
Normal file
51
app/Http/Requests/Checkout/CheckoutSessionStatusRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckoutSessionStatusRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$session = $this->route('session');
|
||||
|
||||
if (! $session instanceof CheckoutSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->user();
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $session->user_id === (int) $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'session.required' => 'Checkout-Session fehlt.',
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Http/Requests/Paddle/PaddleCheckoutRequest.php
Normal file
45
app/Http/Requests/Paddle/PaddleCheckoutRequest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Paddle;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PaddleCheckoutRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
'accepted_waiver' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
'accepted_terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class PurchaseConfirmation extends Mailable
|
||||
'user' => $this->purchase->tenant->user,
|
||||
'package' => $this->purchase->package,
|
||||
'packageName' => $this->localizedPackageName(),
|
||||
'priceFormatted' => $this->formattedTotal(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -49,4 +50,45 @@ class PurchaseConfirmation extends Mailable
|
||||
|
||||
return optional($this->purchase->package)->getNameForLocale($locale) ?? '';
|
||||
}
|
||||
|
||||
private function formattedTotal(): string
|
||||
{
|
||||
$totals = $this->purchase->metadata['paddle_totals'] ?? [];
|
||||
$currency = $totals['currency']
|
||||
?? $this->purchase->metadata['currency']
|
||||
?? $this->purchase->package?->currency
|
||||
?? 'EUR';
|
||||
$amount = array_key_exists('total', $totals) ? (float) $totals['total'] : (float) $this->purchase->price;
|
||||
|
||||
$locale = $this->locale ?? app()->getLocale();
|
||||
$formatter = class_exists(\NumberFormatter::class)
|
||||
? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY)
|
||||
: null;
|
||||
|
||||
if ($formatter) {
|
||||
$formatted = $formatter->formatCurrency($amount, $currency);
|
||||
if ($formatted !== false) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
$symbol = match ($currency) {
|
||||
'EUR' => '€',
|
||||
'USD' => '$',
|
||||
default => $currency,
|
||||
};
|
||||
|
||||
return number_format($amount, 2, ',', '.').' '.$symbol;
|
||||
}
|
||||
|
||||
private function mapLocale(string $locale): string
|
||||
{
|
||||
$normalized = strtolower(str_replace('_', '-', $locale));
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($normalized, 'de') => 'de_DE',
|
||||
str_starts_with($normalized, 'en') => 'en_US',
|
||||
default => 'de_DE',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Checkout;
|
||||
|
||||
use App\Mail\PurchaseConfirmation;
|
||||
use App\Mail\Welcome;
|
||||
use App\Models\AbandonedCheckout;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
@@ -13,6 +12,7 @@ use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Ops\PurchaseCreated;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -69,6 +69,10 @@ class CheckoutAssignmentService
|
||||
?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null)
|
||||
?? CheckoutSession::PROVIDER_FREE;
|
||||
|
||||
$totals = $this->resolvePaddleTotals($session, $options['payload'] ?? []);
|
||||
$currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR';
|
||||
$price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total;
|
||||
|
||||
$purchase = PackagePurchase::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -77,30 +81,39 @@ class CheckoutAssignmentService
|
||||
],
|
||||
[
|
||||
'provider' => $providerName,
|
||||
'price' => $session->amount_total,
|
||||
'price' => round($price, 2),
|
||||
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
|
||||
'purchased_at' => now(),
|
||||
'metadata' => array_filter([
|
||||
'payload' => $options['payload'] ?? null,
|
||||
'checkout_session_id' => $session->id,
|
||||
'consents' => $consents ?: null,
|
||||
]),
|
||||
'paddle_totals' => $totals !== [] ? $totals : null,
|
||||
'currency' => $currency,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
]
|
||||
);
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
$tenantPackage = TenantPackage::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
[
|
||||
'price' => $session->amount_total,
|
||||
'price' => round($price, 2),
|
||||
'active' => true,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => $this->resolveExpiry($package, $tenant),
|
||||
]
|
||||
);
|
||||
|
||||
if ($package->type !== 'reseller') {
|
||||
$tenant->forceFill([
|
||||
'subscription_status' => 'active',
|
||||
'subscription_expires_at' => $tenantPackage->expires_at,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($user && $user->pending_purchase) {
|
||||
$this->activateUser($user);
|
||||
}
|
||||
@@ -108,10 +121,6 @@ class CheckoutAssignmentService
|
||||
if ($user) {
|
||||
$mailLocale = $user->preferred_locale ?? app()->getLocale();
|
||||
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
->queue(new Welcome($user));
|
||||
|
||||
if ($purchase->wasRecentlyCreated) {
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
@@ -196,4 +205,63 @@ class CheckoutAssignmentService
|
||||
'pending_purchase' => false,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function resolvePaddleTotals(CheckoutSession $session, array $payload): array
|
||||
{
|
||||
$metadataTotals = $session->provider_metadata['paddle_totals'] ?? null;
|
||||
|
||||
if (is_array($metadataTotals) && $metadataTotals !== []) {
|
||||
return $metadataTotals;
|
||||
}
|
||||
|
||||
$totals = Arr::get($payload, 'details.totals', Arr::get($payload, 'totals', []));
|
||||
if (! is_array($totals) || $totals === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? Arr::get($payload, 'currency_code')
|
||||
?? Arr::get($totals, 'currency')
|
||||
?? Arr::get($payload, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
|
||||
return array_filter([
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
protected function convertMinorAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ class CheckoutWebhookService
|
||||
return true;
|
||||
|
||||
case 'transaction.completed':
|
||||
$this->syncSessionTotals($session, $data);
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paddle_status' => $status ?: 'completed',
|
||||
@@ -146,6 +147,87 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
protected function syncSessionTotals(CheckoutSession $session, array $data): void
|
||||
{
|
||||
$totals = $this->normalizePaddleTotals($data);
|
||||
|
||||
if ($totals === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
if (array_key_exists('subtotal', $totals)) {
|
||||
$updates['amount_subtotal'] = $totals['subtotal'];
|
||||
}
|
||||
|
||||
if (array_key_exists('discount', $totals)) {
|
||||
$updates['amount_discount'] = $totals['discount'];
|
||||
}
|
||||
|
||||
if (array_key_exists('total', $totals)) {
|
||||
$updates['amount_total'] = $totals['total'];
|
||||
}
|
||||
|
||||
if (! empty($totals['currency'])) {
|
||||
$updates['currency'] = $totals['currency'];
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$session->forceFill($updates)->save();
|
||||
}
|
||||
|
||||
$this->mergeProviderMetadata($session, [
|
||||
'paddle_totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function normalizePaddleTotals(array $data): array
|
||||
{
|
||||
$totals = Arr::get($data, 'details.totals', Arr::get($data, 'totals', []));
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? $data['currency_code'] ?? Arr::get($totals, 'currency') ?? Arr::get($data, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
|
||||
return array_filter([
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
protected function convertMinorAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
|
||||
protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool
|
||||
{
|
||||
$subscriptionId = $data['id'] ?? null;
|
||||
@@ -154,8 +236,8 @@ class CheckoutWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $metadata, $subscriptionId);
|
||||
$customData = $this->extractCustomData($data);
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $tenant) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [
|
||||
@@ -165,7 +247,7 @@ class CheckoutWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$package = $this->resolvePackageFromSubscription($data, $metadata, $subscriptionId);
|
||||
$package = $this->resolvePackageFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $package) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [
|
||||
@@ -317,7 +399,7 @@ class CheckoutWebhookService
|
||||
|
||||
protected function isGiftVoucherEvent(array $data): bool
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($data);
|
||||
|
||||
$type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null;
|
||||
|
||||
@@ -336,7 +418,7 @@ class CheckoutWebhookService
|
||||
|
||||
protected function locatePaddleSession(array $data): ?CheckoutSession
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($data);
|
||||
|
||||
if (is_array($metadata)) {
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
@@ -372,4 +454,27 @@ class CheckoutWebhookService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractCustomData(array $data): array
|
||||
{
|
||||
$customData = [];
|
||||
|
||||
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
|
||||
$customData = $data['custom_data'];
|
||||
}
|
||||
|
||||
if (isset($data['customData']) && is_array($data['customData'])) {
|
||||
$customData = array_merge($customData, $data['customData']);
|
||||
}
|
||||
|
||||
if (isset($data['metadata']) && is_array($data['metadata'])) {
|
||||
$customData = array_merge($customData, $data['metadata']);
|
||||
}
|
||||
|
||||
return $customData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class GiftVoucherCheckoutService
|
||||
],
|
||||
],
|
||||
'customer_email' => $data['purchaser_email'],
|
||||
'metadata' => array_filter([
|
||||
'custom_data' => array_filter([
|
||||
'type' => 'gift_voucher',
|
||||
'tier_key' => $tier['key'],
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
|
||||
@@ -4,20 +4,20 @@ namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\NotifyGiftVoucherReminder;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Mail\GiftVoucherIssued;
|
||||
use App\Jobs\NotifyGiftVoucherReminder;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class GiftVoucherService
|
||||
{
|
||||
@@ -28,7 +28,7 @@ class GiftVoucherService
|
||||
*/
|
||||
public function issueFromPaddle(array $payload): GiftVoucher
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($payload);
|
||||
$priceId = $this->resolvePriceId($payload);
|
||||
$amount = $this->resolveAmount($payload);
|
||||
$currency = Str::upper($this->resolveCurrency($payload));
|
||||
@@ -193,7 +193,7 @@ class GiftVoucherService
|
||||
|
||||
protected function resolvePriceId(array $payload): ?string
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($payload);
|
||||
|
||||
if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) {
|
||||
return $metadata['paddle_price_id'];
|
||||
@@ -242,6 +242,29 @@ class GiftVoucherService
|
||||
?? 'EUR';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractCustomData(array $payload): array
|
||||
{
|
||||
$customData = [];
|
||||
|
||||
if (isset($payload['custom_data']) && is_array($payload['custom_data'])) {
|
||||
$customData = $payload['custom_data'];
|
||||
}
|
||||
|
||||
if (isset($payload['customData']) && is_array($payload['customData'])) {
|
||||
$customData = array_merge($customData, $payload['customData']);
|
||||
}
|
||||
|
||||
if (isset($payload['metadata']) && is_array($payload['metadata'])) {
|
||||
$customData = array_merge($customData, $payload['metadata']);
|
||||
}
|
||||
|
||||
return $customData;
|
||||
}
|
||||
|
||||
protected function generateCode(): string
|
||||
{
|
||||
return 'GIFT-'.Str::upper(Str::random(8));
|
||||
|
||||
@@ -16,7 +16,7 @@ class PaddleCheckoutService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_id?: string|null, metadata?: array} $options
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_id?: string|null, metadata?: array, custom_data?: array} $options
|
||||
*/
|
||||
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
|
||||
{
|
||||
@@ -31,7 +31,11 @@ class PaddleCheckoutService
|
||||
'highlight' => $package->slug,
|
||||
]);
|
||||
|
||||
$metadata = $this->buildMetadata($tenant, $package, $options['metadata'] ?? []);
|
||||
$customData = $this->buildMetadata(
|
||||
$tenant,
|
||||
$package,
|
||||
array_merge($options['metadata'] ?? [], $options['custom_data'] ?? [])
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'customer_id' => $customerId,
|
||||
@@ -41,7 +45,7 @@ class PaddleCheckoutService
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'metadata' => $metadata,
|
||||
'custom_data' => $customData,
|
||||
'success_url' => $successUrl,
|
||||
'cancel_url' => $returnUrl,
|
||||
];
|
||||
|
||||
@@ -28,6 +28,14 @@ class PaddleSubscriptionService
|
||||
*/
|
||||
public function metadata(array $subscription): array
|
||||
{
|
||||
return Arr::get($subscription, 'data.metadata', []);
|
||||
$customData = Arr::get($subscription, 'data.custom_data');
|
||||
|
||||
if (is_array($customData)) {
|
||||
return $customData;
|
||||
}
|
||||
|
||||
$metadata = Arr::get($subscription, 'data.metadata');
|
||||
|
||||
return is_array($metadata) ? $metadata : [];
|
||||
}
|
||||
}
|
||||
|
||||
209
lang/de/emails.php
Normal file
209
lang/de/emails.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'welcome' => [
|
||||
'subject' => 'Willkommen bei Fotospiel, :name',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Danke fuer deine Registrierung bei Fotospiel. Dein Konto ist bereit.',
|
||||
'username' => 'Benutzername: :username',
|
||||
'email' => 'E-Mail: :email',
|
||||
'verification' => 'Bitte bestaetige deine E-Mail-Adresse, um alle Funktionen freizuschalten.',
|
||||
'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.',
|
||||
],
|
||||
'purchase' => [
|
||||
'subject' => 'Deine Fotospiel-Bestellung: :package',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'package' => 'Paket: :package',
|
||||
'price' => 'Gesamt: :price',
|
||||
'activation' => 'Dein Paket ist jetzt aktiv. Du kannst dein Event einrichten.',
|
||||
'footer' => 'Fragen? Antworte auf diese E-Mail oder oeffne dein Dashboard.',
|
||||
],
|
||||
'gift_voucher' => [
|
||||
'recipient' => [
|
||||
'subject' => 'Du hast einen Fotospiel-Gutschein erhalten (:amount :currency)',
|
||||
'greeting' => 'Ein Gutschein fuer dich!',
|
||||
'body' => 'Du hast einen Fotospiel-Gutschein im Wert von :amount :currency von :purchaser erhalten.',
|
||||
],
|
||||
'purchaser' => [
|
||||
'subject' => 'Dein Fotospiel-Gutschein (:amount :currency)',
|
||||
'greeting' => 'Danke fuer dein Fotospiel-Geschenk!',
|
||||
'body' => 'Wir haben einen Gutschein im Wert von :amount :currency fuer :recipient erstellt.',
|
||||
'recipient_fallback' => 'deinen Empfaenger',
|
||||
],
|
||||
'message_title' => 'Persoenliche Nachricht',
|
||||
'code_label' => 'Gutscheincode',
|
||||
'redeem_hint' => 'Loese den Gutschein waehrend des Checkouts ein, um dein Event-Paket zu aktivieren.',
|
||||
'printable' => 'Gutschein drucken',
|
||||
'expiry' => 'Gueltig bis :date.',
|
||||
'withdrawal' => 'Widerrufsbelehrung: <a href=":url">Hier lesen</a>.',
|
||||
'footer' => 'Viel Freude beim Event und den besten Momenten.',
|
||||
],
|
||||
'contact_confirmation' => [
|
||||
'subject' => 'Wir haben deine Nachricht erhalten, :name',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Danke fuer deine Nachricht. Wir melden uns in Kuerze.',
|
||||
'footer' => 'Dein Fotospiel-Team',
|
||||
],
|
||||
'contact' => [
|
||||
'subject' => 'Neue Kontaktanfrage',
|
||||
'body' => "Name: :name\nE-Mail: :email\nNachricht:\n:message",
|
||||
],
|
||||
'abandoned_checkout' => [
|
||||
'subject_1h' => 'Noch Interesse an :package?',
|
||||
'subject_24h' => 'Dein :package Checkout wartet',
|
||||
'subject_1w' => 'Letzte Chance fuer deinen :package Checkout',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body_1h' => 'Du hast den Checkout fuer :package gestartet, aber nicht abgeschlossen. Du kannst jederzeit fortfahren.',
|
||||
'body_24h' => 'Dein :package Checkout ist noch offen. Schließe ihn jetzt ab, um dein Paket zu aktivieren.',
|
||||
'body_1w' => 'Wir haben deinen :package Checkout gespeichert. Schliesse ihn jetzt ab, um die Event-App freizuschalten.',
|
||||
'cta_button' => 'Checkout fortsetzen',
|
||||
'cta_link' => 'Falls der Button nicht funktioniert, nutze diesen Link: :url',
|
||||
'benefits_title' => 'Warum jetzt abschliessen?',
|
||||
'benefit1' => 'Sofortige Aktivierung nach Zahlung',
|
||||
'benefit2' => 'Sicherer Checkout mit Paddle',
|
||||
'benefit3' => 'Automatische Rechnungen und Steuerabwicklung',
|
||||
'benefit4' => 'Unterstuetzung, wenn du sie brauchst',
|
||||
'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.',
|
||||
],
|
||||
'package_limits' => [
|
||||
'package_fallback' => 'Paket',
|
||||
'team_fallback' => 'dein Team',
|
||||
'event_fallback' => 'dein Event',
|
||||
'footer' => 'Fragen? Antworte auf diese E-Mail und wir helfen gern.',
|
||||
'package_expiring' => [
|
||||
'subject' => '{1} Dein :package Paket laeuft in :days Tag aus|[2,*] Dein :package Paket laeuft in :days Tagen aus',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => '{1} Dein :package Paket laeuft in :days Tag am :date aus.|[2,*] Dein :package Paket laeuft in :days Tagen am :date aus.',
|
||||
'action' => 'Abrechnung verwalten',
|
||||
],
|
||||
'package_expired' => [
|
||||
'subject' => ':package Paket abgelaufen',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Dein :package Paket ist am :date abgelaufen. Erneuere es, um den Zugriff zu behalten.',
|
||||
'action' => 'Paket erneuern',
|
||||
],
|
||||
'event_threshold' => [
|
||||
'subject' => 'Du hast :percentage% deines Event-Limits genutzt',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Du hast :used von :limit Events im :package Paket genutzt. :remaining verbleibend.',
|
||||
'action' => 'Paket upgraden',
|
||||
],
|
||||
'event_limit' => [
|
||||
'subject' => 'Event-Limit fuer :package erreicht',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Du hast das Event-Limit (:limit) deines :package Pakets erreicht.',
|
||||
'action' => 'Paket upgraden',
|
||||
],
|
||||
'photo_threshold' => [
|
||||
'subject' => 'Du hast :percentage% deines Foto-Limits genutzt',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Dein Event :event hat :used von :limit Fotos im :package Paket genutzt. :remaining verbleibend.',
|
||||
'action' => 'Mehr Fotos hinzufuegen',
|
||||
],
|
||||
'photo_limit' => [
|
||||
'subject' => 'Foto-Limit fuer :event erreicht',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Dein Event :event hat das Foto-Limit des :package Pakets erreicht.',
|
||||
'cta_addon' => 'Du kannst zusaetzliche Foto-Kapazitaet hinzufuegen.',
|
||||
'addon_action' => 'Foto-Add-on kaufen',
|
||||
'action' => 'Event verwalten',
|
||||
],
|
||||
'guest_threshold' => [
|
||||
'subject' => 'Du hast :percentage% deines Gaestelimits genutzt',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Dein Event :event hat :used von :limit Gaesten im :package Paket genutzt. :remaining verbleibend.',
|
||||
'action' => 'Mehr Gaeste hinzufuegen',
|
||||
],
|
||||
'guest_limit' => [
|
||||
'subject' => 'Gaestelimit fuer :event erreicht',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Dein Event :event hat das Gaestelimit des :package Pakets erreicht.',
|
||||
'cta_addon' => 'Du kannst zusaetzliche Gaeste hinzufuegen.',
|
||||
'addon_action' => 'Gaeste-Add-on kaufen',
|
||||
'action' => 'Event verwalten',
|
||||
],
|
||||
'gallery_warning' => [
|
||||
'subject' => '{1} Galerie fuer :event laeuft in :days Tag ab|[2,*] Galerie fuer :event laeuft in :days Tagen ab',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => '{1} Die Galerie fuer :event (Paket :package) laeuft in :days Tag am :date ab.|[2,*] Die Galerie fuer :event (Paket :package) laeuft in :days Tagen am :date ab.',
|
||||
'action' => 'Event oeffnen',
|
||||
],
|
||||
'gallery_expired' => [
|
||||
'subject' => 'Galerie fuer :event abgelaufen',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Die Galerie fuer :event (Paket :package) ist am :date abgelaufen.',
|
||||
'action' => 'Event oeffnen',
|
||||
],
|
||||
],
|
||||
'tenant_feedback' => [
|
||||
'unknown_tenant' => 'Unbekannter Mandant',
|
||||
'unknown' => 'Unbekannt',
|
||||
'subject' => 'Neues Feedback von :tenant (:sentiment)',
|
||||
'tenant' => 'Mandant: :tenant',
|
||||
'category' => 'Kategorie: :category',
|
||||
'sentiment' => 'Stimmung: :sentiment',
|
||||
'event' => 'Event: :event',
|
||||
'rating' => 'Bewertung: :rating',
|
||||
'title' => 'Titel: :subject',
|
||||
'message' => 'Nachricht:',
|
||||
'open' => 'Feedback oeffnen',
|
||||
'received_at' => 'Erhalten am: :date',
|
||||
],
|
||||
'refund' => [
|
||||
'subject' => 'Rueckerstattung fuer :package',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Wir haben eine Rueckerstattung von :amount :currency fuer deinen Kauf verarbeitet (Referenz: :provider_id).',
|
||||
'reason' => 'Grund: :reason',
|
||||
'footer' => 'Wenn du Fragen hast, antworte auf diese E-Mail.',
|
||||
],
|
||||
'ops' => [
|
||||
'purchase' => [
|
||||
'subject' => 'Neuer Kauf: :package',
|
||||
'greeting' => 'Neuer Kauf eingegangen.',
|
||||
'tenant' => 'Mandant: :tenant',
|
||||
'package' => 'Paket: :package',
|
||||
'amount' => 'Betrag: :amount :currency',
|
||||
'provider' => 'Provider: :provider (ID: :id)',
|
||||
'consents' => 'Einwilligungen: legal=:legal, terms=:terms, waiver=:waiver',
|
||||
'footer' => 'Details im Admin-Panel pruefen.',
|
||||
],
|
||||
'refund' => [
|
||||
'subject' => 'Rueckerstattung: :package',
|
||||
'greeting' => 'Rueckerstattung-Update.',
|
||||
'tenant' => 'Mandant: :tenant',
|
||||
'package' => 'Paket: :package',
|
||||
'amount' => 'Betrag: :amount :currency',
|
||||
'provider' => 'Provider: :provider (ID: :id)',
|
||||
'status_success' => 'Rueckerstattung erfolgreich.',
|
||||
'status_failed' => 'Rueckerstattung fehlgeschlagen.',
|
||||
'reason' => 'Grund: :reason',
|
||||
'error' => 'Fehler: :error',
|
||||
'footer' => 'Bitte Zahlungslogs pruefen.',
|
||||
],
|
||||
'addon' => [
|
||||
'subject' => 'Add-on gekauft: :addon',
|
||||
'greeting' => 'Add-on Kauf eingegangen.',
|
||||
'tenant' => 'Mandant: :tenant',
|
||||
'event' => 'Event: :event',
|
||||
'addon' => 'Add-on: :addon (Menge: :quantity)',
|
||||
'amount' => 'Betrag: :amount :currency',
|
||||
'provider' => 'Provider: :provider (ID: :id)',
|
||||
'footer' => 'Details im Admin-Panel pruefen.',
|
||||
],
|
||||
],
|
||||
'addons' => [
|
||||
'receipt' => [
|
||||
'subject' => 'Add-on Beleg: :addon',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Du hast das Add-on :addon fuer das Event :event gekauft. Betrag: :amount.',
|
||||
'summary_title' => 'Enthalten:',
|
||||
'summary' => [
|
||||
'photos' => ':count zusaetzliche Fotos',
|
||||
'guests' => ':count zusaetzliche Gaeste',
|
||||
'gallery' => ':count zusaetzliche Galerietage',
|
||||
],
|
||||
'unknown_amount' => 'Unbekannter Betrag',
|
||||
'action' => 'Event oeffnen',
|
||||
],
|
||||
],
|
||||
];
|
||||
209
lang/en/emails.php
Normal file
209
lang/en/emails.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'welcome' => [
|
||||
'subject' => 'Welcome to Fotospiel, :name',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'Thanks for registering with Fotospiel. Your account is ready.',
|
||||
'username' => 'Username: :username',
|
||||
'email' => 'Email: :email',
|
||||
'verification' => 'Please confirm your email to unlock all features.',
|
||||
'footer' => 'Need help? Reply to this email and we will be happy to assist.',
|
||||
],
|
||||
'purchase' => [
|
||||
'subject' => 'Your Fotospiel order: :package',
|
||||
'greeting' => 'Hi :name,',
|
||||
'package' => 'Package: :package',
|
||||
'price' => 'Total: :price',
|
||||
'activation' => 'Your package is now active. You can start setting up your event.',
|
||||
'footer' => 'Questions? Reply to this email or open your dashboard.',
|
||||
],
|
||||
'gift_voucher' => [
|
||||
'recipient' => [
|
||||
'subject' => 'You received a Fotospiel gift voucher (:amount :currency)',
|
||||
'greeting' => 'A gift voucher for you!',
|
||||
'body' => 'You received a Fotospiel voucher worth :amount :currency from :purchaser.',
|
||||
],
|
||||
'purchaser' => [
|
||||
'subject' => 'Your Fotospiel gift voucher (:amount :currency)',
|
||||
'greeting' => 'Thanks for gifting Fotospiel!',
|
||||
'body' => 'We created a voucher worth :amount :currency for :recipient.',
|
||||
'recipient_fallback' => 'your recipient',
|
||||
],
|
||||
'message_title' => 'Personal message',
|
||||
'code_label' => 'Voucher code',
|
||||
'redeem_hint' => 'Redeem this voucher during checkout to activate your event package.',
|
||||
'printable' => 'Printable voucher',
|
||||
'expiry' => 'Valid until :date.',
|
||||
'withdrawal' => 'Withdrawal policy: <a href=":url">Read here</a>.',
|
||||
'footer' => 'Enjoy the event and capture the best moments.',
|
||||
],
|
||||
'contact_confirmation' => [
|
||||
'subject' => 'We received your message, :name',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'Thanks for reaching out. We will get back to you shortly.',
|
||||
'footer' => 'Your Fotospiel team',
|
||||
],
|
||||
'contact' => [
|
||||
'subject' => 'New contact request',
|
||||
'body' => "Name: :name\nEmail: :email\nMessage:\n:message",
|
||||
],
|
||||
'abandoned_checkout' => [
|
||||
'subject_1h' => 'Still interested in :package?',
|
||||
'subject_24h' => 'Your :package checkout is waiting',
|
||||
'subject_1w' => 'Last chance to finish your :package checkout',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body_1h' => 'You started the checkout for :package but did not finish. Continue whenever you are ready.',
|
||||
'body_24h' => 'Your :package checkout is still open. Complete it now to activate your package.',
|
||||
'body_1w' => 'We saved your :package checkout. Finish now to unlock your event app.',
|
||||
'cta_button' => 'Resume checkout',
|
||||
'cta_link' => 'If the button does not work, use this link: :url',
|
||||
'benefits_title' => 'Why finish now?',
|
||||
'benefit1' => 'Instant activation after payment',
|
||||
'benefit2' => 'Secure checkout with Paddle',
|
||||
'benefit3' => 'Automatic invoices and tax handling',
|
||||
'benefit4' => 'Friendly support whenever you need help',
|
||||
'footer' => 'Need help? Reply to this email.',
|
||||
],
|
||||
'package_limits' => [
|
||||
'package_fallback' => 'package',
|
||||
'team_fallback' => 'your team',
|
||||
'event_fallback' => 'your event',
|
||||
'footer' => 'Questions? Reply to this email and we will help.',
|
||||
'package_expiring' => [
|
||||
'subject' => '{1} Your :package package expires in :days day|[2,*] Your :package package expires in :days days',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => '{1} Your :package package expires in :days day on :date.|[2,*] Your :package package expires in :days days on :date.',
|
||||
'action' => 'Manage billing',
|
||||
],
|
||||
'package_expired' => [
|
||||
'subject' => ':package package expired',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'Your :package package expired on :date. Renew to keep your access active.',
|
||||
'action' => 'Renew package',
|
||||
],
|
||||
'event_threshold' => [
|
||||
'subject' => 'You have used :percentage% of your event limit',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'You have used :used of :limit events on your :package package. :remaining remaining.',
|
||||
'action' => 'Upgrade package',
|
||||
],
|
||||
'event_limit' => [
|
||||
'subject' => 'Event limit reached for :package',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'You have reached the event limit (:limit) for your :package package.',
|
||||
'action' => 'Upgrade package',
|
||||
],
|
||||
'photo_threshold' => [
|
||||
'subject' => 'You have used :percentage% of your photo limit',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'Your event :event has used :used of :limit photos on :package. :remaining remaining.',
|
||||
'action' => 'Add more photos',
|
||||
],
|
||||
'photo_limit' => [
|
||||
'subject' => 'Photo limit reached for :event',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'Your event :event reached the photo limit for :package.',
|
||||
'cta_addon' => 'You can add extra photo capacity.',
|
||||
'addon_action' => 'Buy photo add-on',
|
||||
'action' => 'Manage event',
|
||||
],
|
||||
'guest_threshold' => [
|
||||
'subject' => 'You have used :percentage% of your guest limit',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'Your event :event has used :used of :limit guests on :package. :remaining remaining.',
|
||||
'action' => 'Add more guests',
|
||||
],
|
||||
'guest_limit' => [
|
||||
'subject' => 'Guest limit reached for :event',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'Your event :event reached the guest limit for :package.',
|
||||
'cta_addon' => 'You can add extra guests.',
|
||||
'addon_action' => 'Buy guest add-on',
|
||||
'action' => 'Manage event',
|
||||
],
|
||||
'gallery_warning' => [
|
||||
'subject' => '{1} Gallery for :event expires in :days day|[2,*] Gallery for :event expires in :days days',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => '{1} The gallery for :event (package :package) expires in :days day on :date.|[2,*] The gallery for :event (package :package) expires in :days days on :date.',
|
||||
'action' => 'Open event',
|
||||
],
|
||||
'gallery_expired' => [
|
||||
'subject' => 'Gallery expired for :event',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'The gallery for :event (package :package) expired on :date.',
|
||||
'action' => 'Open event',
|
||||
],
|
||||
],
|
||||
'tenant_feedback' => [
|
||||
'unknown_tenant' => 'Unknown tenant',
|
||||
'unknown' => 'Unknown',
|
||||
'subject' => 'New feedback from :tenant (:sentiment)',
|
||||
'tenant' => 'Tenant: :tenant',
|
||||
'category' => 'Category: :category',
|
||||
'sentiment' => 'Sentiment: :sentiment',
|
||||
'event' => 'Event: :event',
|
||||
'rating' => 'Rating: :rating',
|
||||
'title' => 'Title: :subject',
|
||||
'message' => 'Message:',
|
||||
'open' => 'Open feedback',
|
||||
'received_at' => 'Received at: :date',
|
||||
],
|
||||
'refund' => [
|
||||
'subject' => 'Refund processed for :package',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'We processed a refund of :amount :currency for your purchase (reference: :provider_id).',
|
||||
'reason' => 'Reason: :reason',
|
||||
'footer' => 'If you have any questions, reply to this email.',
|
||||
],
|
||||
'ops' => [
|
||||
'purchase' => [
|
||||
'subject' => 'New purchase: :package',
|
||||
'greeting' => 'New purchase received.',
|
||||
'tenant' => 'Tenant: :tenant',
|
||||
'package' => 'Package: :package',
|
||||
'amount' => 'Amount: :amount :currency',
|
||||
'provider' => 'Provider: :provider (ID: :id)',
|
||||
'consents' => 'Consents: legal=:legal, terms=:terms, waiver=:waiver',
|
||||
'footer' => 'View the purchase details in the admin panel.',
|
||||
],
|
||||
'refund' => [
|
||||
'subject' => 'Refund processed: :package',
|
||||
'greeting' => 'Refund update.',
|
||||
'tenant' => 'Tenant: :tenant',
|
||||
'package' => 'Package: :package',
|
||||
'amount' => 'Amount: :amount :currency',
|
||||
'provider' => 'Provider: :provider (ID: :id)',
|
||||
'status_success' => 'Refund succeeded.',
|
||||
'status_failed' => 'Refund failed.',
|
||||
'reason' => 'Reason: :reason',
|
||||
'error' => 'Error: :error',
|
||||
'footer' => 'Check the payment logs for details.',
|
||||
],
|
||||
'addon' => [
|
||||
'subject' => 'Add-on purchased: :addon',
|
||||
'greeting' => 'Add-on purchase received.',
|
||||
'tenant' => 'Tenant: :tenant',
|
||||
'event' => 'Event: :event',
|
||||
'addon' => 'Add-on: :addon (qty: :quantity)',
|
||||
'amount' => 'Amount: :amount :currency',
|
||||
'provider' => 'Provider: :provider (ID: :id)',
|
||||
'footer' => 'Review the add-on details in the admin panel.',
|
||||
],
|
||||
],
|
||||
'addons' => [
|
||||
'receipt' => [
|
||||
'subject' => 'Add-on receipt: :addon',
|
||||
'greeting' => 'Hi :name,',
|
||||
'body' => 'You purchased the add-on :addon for event :event. Amount: :amount.',
|
||||
'summary_title' => 'Included:',
|
||||
'summary' => [
|
||||
'photos' => ':count additional photos',
|
||||
'guests' => ':count additional guests',
|
||||
'gallery' => ':count additional gallery days',
|
||||
],
|
||||
'unknown_amount' => 'Unknown amount',
|
||||
'action' => 'Open event',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -581,7 +581,13 @@
|
||||
"status_ready_title": "Checkout geöffnet",
|
||||
"status_error_title": "Zahlung fehlgeschlagen",
|
||||
"status_success_title": "Zahlung abgeschlossen",
|
||||
"status_retry": "Erneut versuchen"
|
||||
"status_retry": "Erneut versuchen",
|
||||
"processing_title": "Zahlung wird verarbeitet...",
|
||||
"processing_body": "Wir haben deine Zahlung erhalten und aktivieren dein Paket. Das kann einen Moment dauern.",
|
||||
"processing_manual_hint": "Falls es zu lange dauert, pruefe den Status erneut oder lade die Seite neu.",
|
||||
"processing_retry": "Status erneut pruefen",
|
||||
"processing_refresh": "Seite neu laden",
|
||||
"processing_confirmation": "Zahlung eingegangen. Wir schliessen deine Bestellung ab..."
|
||||
},
|
||||
"confirmation_step": {
|
||||
"title": "Bestätigung",
|
||||
@@ -1019,4 +1025,4 @@
|
||||
"privacy": "Datenschutz",
|
||||
"terms": "AGB"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,7 +574,13 @@
|
||||
"status_ready_title": "Checkout opened",
|
||||
"status_error_title": "Payment failed",
|
||||
"status_success_title": "Payment completed",
|
||||
"status_retry": "Retry"
|
||||
"status_retry": "Retry",
|
||||
"processing_title": "Processing payment...",
|
||||
"processing_body": "We have received your payment and are activating your package. This can take a minute.",
|
||||
"processing_manual_hint": "If this takes too long, try again or refresh the page.",
|
||||
"processing_retry": "Retry status check",
|
||||
"processing_refresh": "Refresh page",
|
||||
"processing_confirmation": "Payment received. Finalising your order..."
|
||||
},
|
||||
"confirmation_step": {
|
||||
"title": "Confirmation",
|
||||
@@ -1012,4 +1018,4 @@
|
||||
"privacy": "Privacy",
|
||||
"terms": "Terms & Conditions"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ interface LoginFormProps {
|
||||
onSuccess?: (userData: AuthUserPayload | null) => void;
|
||||
canResetPassword?: boolean;
|
||||
locale?: string;
|
||||
packageId?: number | null;
|
||||
}
|
||||
|
||||
type SharedPageProps = {
|
||||
@@ -33,7 +34,7 @@ type FieldErrors = Record<string, string>;
|
||||
|
||||
const csrfToken = () => (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? "";
|
||||
|
||||
export default function LoginForm({ onSuccess, canResetPassword = true, locale }: LoginFormProps) {
|
||||
export default function LoginForm({ onSuccess, canResetPassword = true, locale, packageId }: LoginFormProps) {
|
||||
const page = usePage<SharedPageProps>();
|
||||
const { t } = useTranslation("auth");
|
||||
const resolvedLocale = locale ?? page.props.locale ?? "de";
|
||||
@@ -103,6 +104,7 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale }
|
||||
password: values.password,
|
||||
remember: values.remember,
|
||||
locale: resolvedLocale,
|
||||
package_id: packageId ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -85,15 +85,6 @@ const WizardBody: React.FC<{
|
||||
const hasMountedRef = useRef(false);
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const isFreeSelected = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const priceValue = Number(selectedPackage.price);
|
||||
return Number.isFinite(priceValue) && priceValue <= 0;
|
||||
}, [selectedPackage]);
|
||||
|
||||
const stepConfig = useMemo(() =>
|
||||
baseStepConfig.map(step => ({
|
||||
id: step.id,
|
||||
@@ -159,11 +150,11 @@ const WizardBody: React.FC<{
|
||||
}
|
||||
|
||||
if (currentStep === 'payment') {
|
||||
return isFreeSelected || paymentCompleted;
|
||||
return paymentCompleted;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [atLastStep, authUser, currentStep, isAuthenticated, isFreeSelected, paymentCompleted, selectedPackage]);
|
||||
}, [atLastStep, authUser, currentStep, isAuthenticated, paymentCompleted, selectedPackage]);
|
||||
|
||||
const shouldShowNextButton = useMemo(() => currentStep !== 'confirmation', [currentStep]);
|
||||
const highlightNextCta = currentStep === 'payment' && paymentCompleted;
|
||||
|
||||
@@ -206,6 +206,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
|
||||
<LoginForm
|
||||
locale={locale}
|
||||
onSuccess={handleLoginSuccess}
|
||||
packageId={selectedPackage?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -158,6 +158,12 @@ export const PaymentStep: React.FC = () => {
|
||||
const RateLimitHelper = useRateLimitHelper('coupon');
|
||||
const [voucherExpiry, setVoucherExpiry] = useState<string | null>(null);
|
||||
const [isGiftVoucher, setIsGiftVoucher] = useState(false);
|
||||
const [checkoutSessionId, setCheckoutSessionId] = useState<string | null>(null);
|
||||
const [freeActivationBusy, setFreeActivationBusy] = useState(false);
|
||||
const [awaitingConfirmation, setAwaitingConfirmation] = useState(false);
|
||||
const [confirmationElapsedMs, setConfirmationElapsedMs] = useState(0);
|
||||
const confirmationTimerRef = useRef<number | null>(null);
|
||||
const statusCheckRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const paddleLocale = useMemo(() => {
|
||||
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
|
||||
@@ -253,13 +259,57 @@ export const PaymentStep: React.FC = () => {
|
||||
}, [couponCode]);
|
||||
|
||||
const handleFreeActivation = async () => {
|
||||
if (!selectedPackage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)) {
|
||||
setConsentError(t('checkout.legal.checkbox_terms_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPaymentCompleted(true);
|
||||
nextStep();
|
||||
setConsentError(null);
|
||||
setFreeActivationBusy(true);
|
||||
setAwaitingConfirmation(false);
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
const response = await fetch('/checkout/free-activate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
accepted_terms: acceptedTerms,
|
||||
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
||||
locale: paddleLocale,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paddle_error');
|
||||
setConsentError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckoutSessionId(payload?.checkout_session_id ?? null);
|
||||
setPaymentCompleted(true);
|
||||
nextStep();
|
||||
} catch (error) {
|
||||
console.error('Failed to activate free package', error);
|
||||
const fallbackMessage = t('checkout.payment_step.paddle_error');
|
||||
setConsentError(fallbackMessage);
|
||||
toast.error(fallbackMessage);
|
||||
} finally {
|
||||
setFreeActivationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPaddleCheckout = async () => {
|
||||
@@ -282,6 +332,9 @@ export const PaymentStep: React.FC = () => {
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.paddle_preparing'));
|
||||
setInlineActive(false);
|
||||
setCheckoutSessionId(null);
|
||||
setAwaitingConfirmation(false);
|
||||
setConfirmationElapsedMs(0);
|
||||
|
||||
try {
|
||||
const inlineSupported = initialised && !!paddleConfig?.client_token;
|
||||
@@ -297,7 +350,65 @@ export const PaymentStep: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (inlineSupported) {
|
||||
const response = await fetch('/paddle/create-checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
locale: paddleLocale,
|
||||
coupon_code: couponPreview?.coupon.code ?? undefined,
|
||||
accepted_terms: acceptedTerms,
|
||||
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
||||
inline: inlineSupported,
|
||||
}),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
|
||||
}
|
||||
|
||||
let data: { checkout_url?: string; message?: string } | null = null;
|
||||
try {
|
||||
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse Paddle checkout payload as JSON', parseError);
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (data && typeof (data as { checkout_session_id?: string }).checkout_session_id === 'string') {
|
||||
setCheckoutSessionId((data as { checkout_session_id?: string }).checkout_session_id ?? null);
|
||||
}
|
||||
|
||||
let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null;
|
||||
|
||||
if (!checkoutUrl) {
|
||||
const trimmed = rawBody.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
checkoutUrl = trimmed;
|
||||
} else if (trimmed.startsWith('<')) {
|
||||
const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/);
|
||||
if (match) {
|
||||
checkoutUrl = match[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok || !checkoutUrl) {
|
||||
const message = data?.message || rawBody || 'Unable to create Paddle checkout.';
|
||||
if (response.ok && data && (data as { mode?: string }).mode === 'inline') {
|
||||
checkoutUrl = null;
|
||||
} else {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (data && (data as { mode?: string }).mode === 'inline') {
|
||||
const paddle = paddleRef.current;
|
||||
|
||||
if (!paddle || !paddle.Checkout || typeof paddle.Checkout.open !== 'function') {
|
||||
@@ -305,31 +416,23 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
|
||||
const inlinePayload: Record<string, unknown> = {
|
||||
items: [
|
||||
{
|
||||
priceId: selectedPackage.paddle_price_id,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
items: (data as { items?: unknown[] }).items ?? [],
|
||||
settings: {
|
||||
displayMode: 'inline',
|
||||
frameTarget: checkoutContainerClass,
|
||||
frameInitialHeight: '550',
|
||||
frameStyle: 'width: 100%; min-width: 320px; background-color: transparent; border: none;',
|
||||
theme: 'light',
|
||||
locale: paddleLocale,
|
||||
},
|
||||
customData: {
|
||||
package_id: String(selectedPackage.id),
|
||||
locale: paddleLocale,
|
||||
accepted_terms: acceptedTerms ? '1' : '0',
|
||||
accepted_waiver: requiresImmediateWaiver && acceptedWaiver ? '1' : '0',
|
||||
},
|
||||
};
|
||||
locale: paddleLocale,
|
||||
},
|
||||
};
|
||||
|
||||
const customerEmail = authUser?.email ?? null;
|
||||
if (customerEmail) {
|
||||
inlinePayload.customer = { email: customerEmail };
|
||||
if ((data as { custom_data?: Record<string, unknown> }).custom_data) {
|
||||
inlinePayload.customData = (data as { custom_data?: Record<string, unknown> }).custom_data;
|
||||
}
|
||||
|
||||
if ((data as { customer?: Record<string, unknown> }).customer) {
|
||||
inlinePayload.customer = (data as { customer?: Record<string, unknown> }).customer;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -356,54 +459,6 @@ export const PaymentStep: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/paddle/create-checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
package_id: selectedPackage.id,
|
||||
locale: paddleLocale,
|
||||
coupon_code: couponPreview?.coupon.code ?? undefined,
|
||||
accepted_terms: acceptedTerms,
|
||||
accepted_waiver: requiresImmediateWaiver ? acceptedWaiver : false,
|
||||
}),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
console.info('[Checkout] Hosted checkout response', { status: response.status, rawBody });
|
||||
}
|
||||
|
||||
let data: { checkout_url?: string; message?: string } | null = null;
|
||||
try {
|
||||
data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null;
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse Paddle checkout payload as JSON', parseError);
|
||||
data = null;
|
||||
}
|
||||
|
||||
let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null;
|
||||
|
||||
if (!checkoutUrl) {
|
||||
const trimmed = rawBody.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
checkoutUrl = trimmed;
|
||||
} else if (trimmed.startsWith('<')) {
|
||||
const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/);
|
||||
if (match) {
|
||||
checkoutUrl = match[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok || !checkoutUrl) {
|
||||
const message = data?.message || rawBody || 'Unable to create Paddle checkout.';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
window.open(checkoutUrl, '_blank', 'noopener');
|
||||
setInlineActive(false);
|
||||
setStatus('ready');
|
||||
@@ -434,14 +489,15 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
|
||||
if (event.name === 'checkout.completed') {
|
||||
setStatus('ready');
|
||||
setMessage(t('checkout.payment_step.paddle_overlay_ready'));
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.processing_confirmation'));
|
||||
setInlineActive(false);
|
||||
setPaymentCompleted(true);
|
||||
setAwaitingConfirmation(true);
|
||||
setPaymentCompleted(false);
|
||||
toast.success(t('checkout.payment_step.toast_success'));
|
||||
}
|
||||
|
||||
if (event.name === 'checkout.closed') {
|
||||
if (event.name === 'checkout.closed' && !awaitingConfirmation) {
|
||||
setStatus('idle');
|
||||
setMessage('');
|
||||
setInlineActive(false);
|
||||
@@ -452,6 +508,7 @@ export const PaymentStep: React.FC = () => {
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paddle_error'));
|
||||
setInlineActive(false);
|
||||
setAwaitingConfirmation(false);
|
||||
setPaymentCompleted(false);
|
||||
}
|
||||
};
|
||||
@@ -502,12 +559,130 @@ export const PaymentStep: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
|
||||
}, [awaitingConfirmation, paddleConfig?.environment, paddleConfig?.client_token, paddleLocale, setPaymentCompleted, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setPaymentCompleted(false);
|
||||
setCheckoutSessionId(null);
|
||||
setStatus('idle');
|
||||
setMessage('');
|
||||
setInlineActive(false);
|
||||
setAwaitingConfirmation(false);
|
||||
setConfirmationElapsedMs(0);
|
||||
}, [selectedPackage?.id, setPaymentCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!awaitingConfirmation || typeof window === 'undefined') {
|
||||
if (confirmationTimerRef.current) {
|
||||
window.clearInterval(confirmationTimerRef.current);
|
||||
confirmationTimerRef.current = null;
|
||||
}
|
||||
setConfirmationElapsedMs(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
confirmationTimerRef.current = window.setInterval(() => {
|
||||
setConfirmationElapsedMs(Date.now() - startedAt);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (confirmationTimerRef.current) {
|
||||
window.clearInterval(confirmationTimerRef.current);
|
||||
confirmationTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [awaitingConfirmation]);
|
||||
|
||||
const checkSessionStatus = useCallback(async (): Promise<boolean> => {
|
||||
if (!checkoutSessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/checkout/session/${checkoutSessionId}/status`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (payload?.status === 'completed') {
|
||||
setStatus('ready');
|
||||
setMessage(t('checkout.payment_step.status_success'));
|
||||
setInlineActive(false);
|
||||
setAwaitingConfirmation(false);
|
||||
setPaymentCompleted(true);
|
||||
toast.success(t('checkout.payment_step.toast_success'));
|
||||
nextStep();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (payload?.status === 'failed' || payload?.status === 'cancelled') {
|
||||
setStatus('error');
|
||||
setMessage(t('checkout.payment_step.paddle_error'));
|
||||
setAwaitingConfirmation(false);
|
||||
setPaymentCompleted(false);
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [checkoutSessionId, nextStep, setPaymentCompleted, t]);
|
||||
|
||||
useEffect(() => {
|
||||
statusCheckRef.current = () => {
|
||||
void checkSessionStatus();
|
||||
};
|
||||
}, [checkSessionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkoutSessionId || paymentCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const schedulePoll = () => {
|
||||
if (cancelled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
void pollStatus();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completed = await checkSessionStatus();
|
||||
|
||||
if (!completed) {
|
||||
schedulePoll();
|
||||
}
|
||||
};
|
||||
|
||||
void pollStatus();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId && typeof window !== 'undefined') {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [checkSessionStatus, checkoutSessionId, paymentCompleted]);
|
||||
|
||||
const handleCouponSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -553,6 +728,20 @@ export const PaymentStep: React.FC = () => {
|
||||
}
|
||||
}, [paddleLocale, t, withdrawalHtml, withdrawalLoading]);
|
||||
|
||||
const showManualActions = awaitingConfirmation && confirmationElapsedMs >= 30000;
|
||||
|
||||
const handleStatusRetry = useCallback(() => {
|
||||
setStatus('processing');
|
||||
setMessage(t('checkout.payment_step.processing_confirmation'));
|
||||
statusCheckRef.current?.();
|
||||
}, [t]);
|
||||
|
||||
const handlePageRefresh = useCallback(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!selectedPackage) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
@@ -569,8 +758,78 @@ export const PaymentStep: React.FC = () => {
|
||||
<AlertTitle>{t('checkout.payment_step.free_package_title')}</AlertTitle>
|
||||
<AlertDescription>{t('checkout.payment_step.free_package_desc')}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="checkout-terms-free"
|
||||
checked={acceptedTerms}
|
||||
onCheckedChange={(checked) => {
|
||||
setAcceptedTerms(Boolean(checked));
|
||||
if (consentError) {
|
||||
setConsentError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1 text-sm">
|
||||
<Label htmlFor="checkout-terms-free" className="cursor-pointer">
|
||||
{t('checkout.legal.checkbox_terms_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('checkout.legal.legal_links_intro')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openWithdrawalModal();
|
||||
}}
|
||||
>
|
||||
{t('checkout.legal.open_withdrawal')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresImmediateWaiver && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="checkout-waiver-free"
|
||||
checked={acceptedWaiver}
|
||||
onCheckedChange={(checked) => {
|
||||
setAcceptedWaiver(Boolean(checked));
|
||||
if (consentError) {
|
||||
setConsentError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1 text-sm">
|
||||
<Label htmlFor="checkout-waiver-free" className="cursor-pointer">
|
||||
{t('checkout.legal.checkbox_digital_content_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('checkout.legal.hint_subscription_withdrawal')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{consentError && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span>{consentError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="lg" onClick={handleFreeActivation}>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleFreeActivation}
|
||||
disabled={freeActivationBusy || !acceptedTerms || (requiresImmediateWaiver && !acceptedWaiver)}
|
||||
>
|
||||
{freeActivationBusy && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('checkout.payment_step.activate_package')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -578,6 +837,34 @@ export const PaymentStep: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (awaitingConfirmation) {
|
||||
return (
|
||||
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<LoaderCircle className="h-6 w-6 animate-spin text-primary" />
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">{t('checkout.payment_step.processing_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.processing_body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showManualActions && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t('checkout.payment_step.processing_manual_hint')}</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button type="button" variant="outline" onClick={handleStatusRetry}>
|
||||
{t('checkout.payment_step.processing_retry')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handlePageRefresh}>
|
||||
{t('checkout.payment_step.processing_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
||||
<Icon className="h-4 w-4 text-white/80" />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<body>
|
||||
<h1>{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}</h1>
|
||||
<p>{{ __('emails.purchase.package', ['package' => $packageName]) }}</p>
|
||||
<p>{{ __('emails.purchase.price', ['price' => $purchase->price]) }}</p>
|
||||
<p>{{ __('emails.purchase.price', ['price' => $priceFormatted]) }}</p>
|
||||
<p>{{ __('emails.purchase.activation') }}</p>
|
||||
<p>{!! __('emails.purchase.footer') !!}</p>
|
||||
</body>
|
||||
|
||||
@@ -366,6 +366,10 @@ Route::post('/checkout/track-abandoned', [CheckoutController::class, 'trackAband
|
||||
Route::post('/set-locale', [LocaleController::class, 'set'])->name('set-locale');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::post('/checkout/free-activate', [CheckoutController::class, 'activateFree'])->name('checkout.free-activate');
|
||||
Route::get('/checkout/session/{session}/status', [CheckoutController::class, 'sessionStatus'])
|
||||
->whereUuid('session')
|
||||
->name('checkout.session.status');
|
||||
Route::post('/paddle/create-checkout', [PaddleCheckoutController::class, 'create'])->name('paddle.checkout.create');
|
||||
});
|
||||
|
||||
|
||||
@@ -67,6 +67,28 @@ class CheckoutAuthTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_checkout_login_marks_pending_purchase_when_package_provided(): void
|
||||
{
|
||||
$user = User::factory()->create(['pending_purchase' => false]);
|
||||
$package = Package::factory()->create();
|
||||
|
||||
$response = $this->postJson(route('checkout.login'), [
|
||||
'identifier' => $user->email,
|
||||
'password' => 'password',
|
||||
'remember' => false,
|
||||
'locale' => 'de',
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('user.pending_purchase', true);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $user->id,
|
||||
'pending_purchase' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_checkout_login_returns_validation_errors_with_invalid_credentials()
|
||||
{
|
||||
$response = $this->postJson(route('checkout.login'), [
|
||||
|
||||
91
tests/Feature/Checkout/CheckoutFreeActivationTest.php
Normal file
91
tests/Feature/Checkout/CheckoutFreeActivationTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CheckoutFreeActivationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Mail::fake();
|
||||
Notification::fake();
|
||||
}
|
||||
|
||||
public function test_free_checkout_activation_completes_session_and_assigns_package(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$package = Package::factory()->create([
|
||||
'price' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->postJson(route('checkout.free-activate'), [
|
||||
'package_id' => $package->id,
|
||||
'accepted_terms' => true,
|
||||
'accepted_waiver' => true,
|
||||
'locale' => 'de',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', 'completed');
|
||||
|
||||
$this->assertDatabaseHas('checkout_sessions', [
|
||||
'package_id' => $package->id,
|
||||
'user_id' => $user->id,
|
||||
'provider' => CheckoutSession::PROVIDER_FREE,
|
||||
'status' => CheckoutSession::STATUS_COMPLETED,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('tenant_packages', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => CheckoutSession::PROVIDER_FREE,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_free_checkout_requires_waiver_when_package_activates_immediately(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$package = Package::factory()->create([
|
||||
'price' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->postJson(route('checkout.free-activate'), [
|
||||
'package_id' => $package->id,
|
||||
'accepted_terms' => true,
|
||||
'accepted_waiver' => false,
|
||||
'locale' => 'de',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['accepted_waiver']);
|
||||
|
||||
$this->assertDatabaseMissing('package_purchases', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
tests/Feature/Checkout/CheckoutSessionStatusTest.php
Normal file
57
tests/Feature/Checkout/CheckoutSessionStatusTest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CheckoutSessionStatusTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_fetch_checkout_session_status(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$package = Package::factory()->create();
|
||||
|
||||
/** @var CheckoutSessionService $sessions */
|
||||
$sessions = app(CheckoutSessionService::class);
|
||||
$session = $sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
$sessions->markCompleted($session, now());
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->getJson(route('checkout.session.status', $session));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', CheckoutSession::STATUS_COMPLETED);
|
||||
}
|
||||
|
||||
public function test_user_cannot_fetch_other_users_checkout_session_status(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$owner = User::factory()->for($tenant)->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$package = Package::factory()->create();
|
||||
|
||||
/** @var CheckoutSessionService $sessions */
|
||||
$sessions = app(CheckoutSessionService::class);
|
||||
$session = $sessions->createOrResume($owner, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->actingAs($otherUser);
|
||||
|
||||
$response = $this->getJson(route('checkout.session.status', $session));
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Checkout\CheckoutWebhookService;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use App\Services\Paddle\PaddleSubscriptionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -80,11 +82,15 @@ class PackageSoftDeleteTest extends TestCase
|
||||
$sessionService = Mockery::mock(CheckoutSessionService::class);
|
||||
$assignmentService = Mockery::mock(CheckoutAssignmentService::class);
|
||||
$subscriptionService = Mockery::mock(PaddleSubscriptionService::class);
|
||||
$couponRedemptions = Mockery::mock(CouponRedemptionService::class);
|
||||
$giftVouchers = Mockery::mock(GiftVoucherService::class);
|
||||
|
||||
$service = new CheckoutWebhookService(
|
||||
$sessionService,
|
||||
$assignmentService,
|
||||
$subscriptionService
|
||||
$subscriptionService,
|
||||
$couponRedemptions,
|
||||
$giftVouchers
|
||||
);
|
||||
|
||||
Carbon::setTestNow(now());
|
||||
@@ -94,7 +100,7 @@ class PackageSoftDeleteTest extends TestCase
|
||||
'data' => [
|
||||
'id' => 'sub_123',
|
||||
'status' => 'active',
|
||||
'metadata' => [
|
||||
'custom_data' => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
|
||||
@@ -80,10 +80,15 @@ class PaddleCheckoutControllerTest extends TestCase
|
||||
$response = $this->postJson(route('paddle.checkout.create'), [
|
||||
'package_id' => $package->id,
|
||||
'coupon_code' => 'SAVE15',
|
||||
'accepted_terms' => true,
|
||||
'accepted_waiver' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('checkout_url', 'https://example.com/checkout/test');
|
||||
->assertJsonPath('checkout_url', 'https://example.com/checkout/test')
|
||||
->assertJsonStructure([
|
||||
'checkout_session_id',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('checkout_sessions', [
|
||||
'package_id' => $package->id,
|
||||
|
||||
@@ -30,7 +30,16 @@ class PaddleWebhookControllerTest extends TestCase
|
||||
'id' => 'txn_123',
|
||||
'status' => 'completed',
|
||||
'checkout_id' => 'chk_456',
|
||||
'metadata' => [
|
||||
'details' => [
|
||||
'totals' => [
|
||||
'subtotal' => ['amount' => '10000'],
|
||||
'discount' => ['amount' => '1000'],
|
||||
'tax' => ['amount' => '1900'],
|
||||
'total' => ['amount' => '10900'],
|
||||
'currency_code' => 'EUR',
|
||||
],
|
||||
],
|
||||
'custom_data' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
@@ -66,6 +75,17 @@ class PaddleWebhookControllerTest extends TestCase
|
||||
->where('provider', 'paddle')
|
||||
->exists()
|
||||
);
|
||||
|
||||
$purchase = PackagePurchase::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('package_id', $package->id)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($purchase);
|
||||
$this->assertSame(109.0, (float) $purchase->price);
|
||||
$this->assertSame('EUR', Arr::get($purchase->metadata, 'currency'));
|
||||
$this->assertSame(109.0, (float) Arr::get($purchase->metadata, 'paddle_totals.total'));
|
||||
$this->assertSame(109.0, (float) $session->amount_total);
|
||||
}
|
||||
|
||||
public function test_duplicate_transaction_is_idempotent(): void
|
||||
@@ -80,7 +100,7 @@ class PaddleWebhookControllerTest extends TestCase
|
||||
'id' => 'txn_dup',
|
||||
'status' => 'completed',
|
||||
'checkout_id' => 'chk_dup',
|
||||
'metadata' => [
|
||||
'custom_data' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
@@ -107,6 +127,60 @@ class PaddleWebhookControllerTest extends TestCase
|
||||
$this->assertEquals('txn_dup', Arr::get($session->provider_metadata, 'paddle_transaction_id'));
|
||||
}
|
||||
|
||||
public function test_transaction_completed_updates_tenant_status_for_one_time_package(): void
|
||||
{
|
||||
config(['paddle.webhook_secret' => 'test_secret']);
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$tenant = Tenant::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'subscription_status' => 'free',
|
||||
]);
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
|
||||
$package = Package::factory()->create([
|
||||
'type' => 'endcustomer',
|
||||
'price' => 49,
|
||||
'paddle_price_id' => 'price_one_time',
|
||||
]);
|
||||
|
||||
/** @var CheckoutSessionService $sessions */
|
||||
$sessions = app(CheckoutSessionService::class);
|
||||
$session = $sessions->createOrResume($user, $package, ['tenant' => $tenant]);
|
||||
$sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$payload = [
|
||||
'event_type' => 'transaction.completed',
|
||||
'data' => [
|
||||
'id' => 'txn_one_time',
|
||||
'status' => 'completed',
|
||||
'details' => [
|
||||
'totals' => [
|
||||
'total' => ['amount' => '4900'],
|
||||
'currency_code' => 'EUR',
|
||||
],
|
||||
],
|
||||
'custom_data' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$signature = hash_hmac('sha256', json_encode($payload), 'test_secret');
|
||||
|
||||
$response = $this->withHeader('Paddle-Webhook-Signature', $signature)
|
||||
->postJson('/paddle/webhook', $payload);
|
||||
|
||||
$response->assertOk()->assertJson(['status' => 'processed']);
|
||||
|
||||
$tenant->refresh();
|
||||
|
||||
$this->assertSame('active', $tenant->subscription_status);
|
||||
$this->assertNotNull($tenant->subscription_expires_at);
|
||||
}
|
||||
|
||||
public function test_rejects_invalid_signature(): void
|
||||
{
|
||||
config(['paddle.webhook_secret' => 'secret']);
|
||||
@@ -152,7 +226,7 @@ class PaddleWebhookControllerTest extends TestCase
|
||||
'customer_id' => 'cus_123',
|
||||
'created_at' => Carbon::now()->subDay()->toIso8601String(),
|
||||
'next_billing_date' => Carbon::now()->addMonth()->toIso8601String(),
|
||||
'metadata' => [
|
||||
'custom_data' => [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
],
|
||||
@@ -212,7 +286,7 @@ class PaddleWebhookControllerTest extends TestCase
|
||||
'id' => 'sub_cancel',
|
||||
'status' => 'cancelled',
|
||||
'customer_id' => 'cus_cancel',
|
||||
'metadata' => [
|
||||
'custom_data' => [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
],
|
||||
|
||||
@@ -58,7 +58,7 @@ class EventAddonWebhookTest extends TenantTestCase
|
||||
'event_type' => 'transaction.completed',
|
||||
'data' => [
|
||||
'id' => 'txn_addon_1',
|
||||
'metadata' => [
|
||||
'custom_data' => [
|
||||
'addon_intent' => 'intent-123',
|
||||
'addon_key' => 'extra_guests',
|
||||
],
|
||||
|
||||
@@ -40,7 +40,7 @@ class GiftVoucherCheckoutServiceTest extends TestCase
|
||||
->with('/checkout/links', Mockery::on(function ($payload) {
|
||||
return $payload['items'][0]['price_id'] === 'pri_a'
|
||||
&& $payload['customer_email'] === 'buyer@example.com'
|
||||
&& $payload['metadata']['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']]);
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace Tests\Unit;
|
||||
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Mail\GiftVoucherIssued;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\Package;
|
||||
use App\Mail\GiftVoucherIssued;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
@@ -35,7 +35,7 @@ class GiftVoucherServiceTest extends TestCase
|
||||
'amount' => 5900,
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'custom_data' => [
|
||||
'type' => 'gift_card',
|
||||
'purchaser_email' => 'buyer@example.com',
|
||||
'recipient_email' => 'friend@example.com',
|
||||
@@ -104,7 +104,7 @@ class GiftVoucherServiceTest extends TestCase
|
||||
'amount' => 2900,
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'custom_data' => [
|
||||
'type' => 'gift_voucher',
|
||||
'purchaser_email' => 'buyer@example.com',
|
||||
'recipient_email' => 'friend@example.com',
|
||||
|
||||
62
tests/Unit/PaddleCheckoutServiceTest.php
Normal file
62
tests/Unit/PaddleCheckoutServiceTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use App\Services\Paddle\PaddleCustomerService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PaddleCheckoutServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_checkout_sends_custom_data_payload(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create([
|
||||
'contact_email' => 'buyer@example.com',
|
||||
]);
|
||||
|
||||
$package = Package::factory()->create([
|
||||
'paddle_price_id' => 'pri_123',
|
||||
]);
|
||||
|
||||
$client = Mockery::mock(PaddleClient::class);
|
||||
$customers = Mockery::mock(PaddleCustomerService::class);
|
||||
|
||||
$customers->shouldReceive('ensureCustomerId')
|
||||
->once()
|
||||
->with($tenant)
|
||||
->andReturn('ctm_123');
|
||||
|
||||
$client->shouldReceive('post')
|
||||
->once()
|
||||
->with('/checkout/links', Mockery::on(function (array $payload) use ($tenant, $package) {
|
||||
return $payload['items'][0]['price_id'] === 'pri_123'
|
||||
&& $payload['customer_id'] === 'ctm_123'
|
||||
&& ($payload['custom_data']['tenant_id'] ?? null) === (string) $tenant->id
|
||||
&& ($payload['custom_data']['package_id'] ?? null) === (string) $package->id
|
||||
&& ($payload['custom_data']['source'] ?? null) === 'test'
|
||||
&& ! isset($payload['metadata']);
|
||||
}))
|
||||
->andReturn(['data' => ['url' => 'https://paddle.test/checkout/123', 'id' => 'chk_123']]);
|
||||
|
||||
$this->app->instance(PaddleClient::class, $client);
|
||||
$this->app->instance(PaddleCustomerService::class, $customers);
|
||||
|
||||
$service = $this->app->make(PaddleCheckoutService::class);
|
||||
|
||||
$checkout = $service->createCheckout($tenant, $package, [
|
||||
'success_url' => 'https://example.test/success',
|
||||
'return_url' => 'https://example.test/cancel',
|
||||
'metadata' => ['source' => 'test'],
|
||||
]);
|
||||
|
||||
$this->assertSame('https://paddle.test/checkout/123', $checkout['checkout_url']);
|
||||
$this->assertSame('chk_123', $checkout['id']);
|
||||
}
|
||||
}
|
||||
@@ -166,10 +166,6 @@ test.describe('Standard package checkout with Paddle completion', () => {
|
||||
expect(session?.status).toBe('completed');
|
||||
}
|
||||
|
||||
const nextButton = page.getByRole('button', { name: /^Weiter$/ }).last();
|
||||
await expect(nextButton).toBeEnabled();
|
||||
await nextButton.click();
|
||||
|
||||
await expect(page.getByText(/Marketing-Dashboard/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Zum Admin-Bereich|To Admin Area/i })
|
||||
|
||||
Reference in New Issue
Block a user