diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 7c33fae..e2f8d42 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -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([ diff --git a/app/Http/Controllers/PaddleCheckoutController.php b/app/Http/Controllers/PaddleCheckoutController.php index ecd1ea7..3b55169 100644 --- a/app/Http/Controllers/PaddleCheckoutController.php +++ b/app/Http/Controllers/PaddleCheckoutController.php @@ -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 diff --git a/app/Http/Controllers/Testing/TestCheckoutController.php b/app/Http/Controllers/Testing/TestCheckoutController.php index bb95f3a..c0893b4 100644 --- a/app/Http/Controllers/Testing/TestCheckoutController.php +++ b/app/Http/Controllers/Testing/TestCheckoutController.php @@ -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(), ]), ]; diff --git a/app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php b/app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php new file mode 100644 index 0000000..b976f94 --- /dev/null +++ b/app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php @@ -0,0 +1,42 @@ +|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.', + ]; + } +} diff --git a/app/Http/Requests/Checkout/CheckoutLoginRequest.php b/app/Http/Requests/Checkout/CheckoutLoginRequest.php new file mode 100644 index 0000000..112464c --- /dev/null +++ b/app/Http/Requests/Checkout/CheckoutLoginRequest.php @@ -0,0 +1,44 @@ +|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.', + ]; + } +} diff --git a/app/Http/Requests/Checkout/CheckoutRegisterRequest.php b/app/Http/Requests/Checkout/CheckoutRegisterRequest.php new file mode 100644 index 0000000..a36f387 --- /dev/null +++ b/app/Http/Requests/Checkout/CheckoutRegisterRequest.php @@ -0,0 +1,54 @@ +|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.', + ]; + } +} diff --git a/app/Http/Requests/Checkout/CheckoutSessionStatusRequest.php b/app/Http/Requests/Checkout/CheckoutSessionStatusRequest.php new file mode 100644 index 0000000..e75e89f --- /dev/null +++ b/app/Http/Requests/Checkout/CheckoutSessionStatusRequest.php @@ -0,0 +1,51 @@ +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> + */ + public function rules(): array + { + return [ + // + ]; + } + + /** + * Get custom validation messages. + */ + public function messages(): array + { + return [ + 'session.required' => 'Checkout-Session fehlt.', + ]; + } +} diff --git a/app/Http/Requests/Paddle/PaddleCheckoutRequest.php b/app/Http/Requests/Paddle/PaddleCheckoutRequest.php new file mode 100644 index 0000000..769321f --- /dev/null +++ b/app/Http/Requests/Paddle/PaddleCheckoutRequest.php @@ -0,0 +1,45 @@ +|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.', + ]; + } +} diff --git a/app/Mail/PurchaseConfirmation.php b/app/Mail/PurchaseConfirmation.php index ca4445c..55ce775 100644 --- a/app/Mail/PurchaseConfirmation.php +++ b/app/Mail/PurchaseConfirmation.php @@ -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', + }; + } } diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index 0c714f0..f0fd5e4 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -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 $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); + } } diff --git a/app/Services/Checkout/CheckoutWebhookService.php b/app/Services/Checkout/CheckoutWebhookService.php index 76c9302..256444c 100644 --- a/app/Services/Checkout/CheckoutWebhookService.php +++ b/app/Services/Checkout/CheckoutWebhookService.php @@ -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 $data + * @return array + */ + 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; + } } diff --git a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php index 64e0007..cc62701 100644 --- a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php +++ b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php @@ -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'], diff --git a/app/Services/GiftVouchers/GiftVoucherService.php b/app/Services/GiftVouchers/GiftVoucherService.php index 513010f..84ab212 100644 --- a/app/Services/GiftVouchers/GiftVoucherService.php +++ b/app/Services/GiftVouchers/GiftVoucherService.php @@ -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 $payload + * @return array + */ + 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)); diff --git a/app/Services/Paddle/PaddleCheckoutService.php b/app/Services/Paddle/PaddleCheckoutService.php index 8740145..9574232 100644 --- a/app/Services/Paddle/PaddleCheckoutService.php +++ b/app/Services/Paddle/PaddleCheckoutService.php @@ -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, ]; diff --git a/app/Services/Paddle/PaddleSubscriptionService.php b/app/Services/Paddle/PaddleSubscriptionService.php index 6c3e671..081c55c 100644 --- a/app/Services/Paddle/PaddleSubscriptionService.php +++ b/app/Services/Paddle/PaddleSubscriptionService.php @@ -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 : []; } } diff --git a/lang/de/emails.php b/lang/de/emails.php new file mode 100644 index 0000000..9ecb1ef --- /dev/null +++ b/lang/de/emails.php @@ -0,0 +1,209 @@ + [ + '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: Hier lesen.', + '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', + ], + ], +]; diff --git a/lang/en/emails.php b/lang/en/emails.php new file mode 100644 index 0000000..266e790 --- /dev/null +++ b/lang/en/emails.php @@ -0,0 +1,209 @@ + [ + '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: Read here.', + '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', + ], + ], +]; diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index f5bf272..76b5018 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -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" } -} +} \ No newline at end of file diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index a2ce3e6..280fdc5 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -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" } -} +} \ No newline at end of file diff --git a/resources/js/pages/auth/LoginForm.tsx b/resources/js/pages/auth/LoginForm.tsx index 769450f..c80a0ac 100644 --- a/resources/js/pages/auth/LoginForm.tsx +++ b/resources/js/pages/auth/LoginForm.tsx @@ -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; 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(); 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, }), }); diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index 162500f..a657d08 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -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; diff --git a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx index 558a647..a23629b 100644 --- a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx @@ -206,6 +206,7 @@ export const AuthStep: React.FC = ({ privacyHtml, googleProfile, )} diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index e8a30e2..11630e0 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -158,6 +158,12 @@ export const PaymentStep: React.FC = () => { const RateLimitHelper = useRateLimitHelper('coupon'); const [voucherExpiry, setVoucherExpiry] = useState(null); const [isGiftVoucher, setIsGiftVoucher] = useState(false); + const [checkoutSessionId, setCheckoutSessionId] = useState(null); + const [freeActivationBusy, setFreeActivationBusy] = useState(false); + const [awaitingConfirmation, setAwaitingConfirmation] = useState(false); + const [confirmationElapsedMs, setConfirmationElapsedMs] = useState(0); + const confirmationTimerRef = useRef(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 = { - 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 }).custom_data) { + inlinePayload.customData = (data as { custom_data?: Record }).custom_data; + } + + if ((data as { customer?: Record }).customer) { + inlinePayload.customer = (data as { customer?: Record }).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 => { + 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) => { 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 ( @@ -569,8 +758,78 @@ export const PaymentStep: React.FC = () => { {t('checkout.payment_step.free_package_title')} {t('checkout.payment_step.free_package_desc')} +
+
+
+ { + setAcceptedTerms(Boolean(checked)); + if (consentError) { + setConsentError(null); + } + }} + /> +
+ +

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

+
+
+ + {requiresImmediateWaiver && ( +
+ { + setAcceptedWaiver(Boolean(checked)); + if (consentError) { + setConsentError(null); + } + }} + /> +
+ +

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

+
+
+ )} + + {consentError && ( +
+ + {consentError} +
+ )} +
+
-
@@ -578,6 +837,34 @@ export const PaymentStep: React.FC = () => { ); } + if (awaitingConfirmation) { + return ( +
+
+ +
+

{t('checkout.payment_step.processing_title')}

+

{t('checkout.payment_step.processing_body')}

+
+
+ + {showManualActions && ( +
+

{t('checkout.payment_step.processing_manual_hint')}

+
+ + +
+
+ )} +
+ ); + } + const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
diff --git a/resources/views/emails/purchase.blade.php b/resources/views/emails/purchase.blade.php index 49e2b5e..59b73cf 100644 --- a/resources/views/emails/purchase.blade.php +++ b/resources/views/emails/purchase.blade.php @@ -6,7 +6,7 @@

{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}

{{ __('emails.purchase.package', ['package' => $packageName]) }}

-

{{ __('emails.purchase.price', ['price' => $purchase->price]) }}

+

{{ __('emails.purchase.price', ['price' => $priceFormatted]) }}

{{ __('emails.purchase.activation') }}

{!! __('emails.purchase.footer') !!}

diff --git a/routes/web.php b/routes/web.php index 59debb1..5b53051 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); diff --git a/tests/Feature/Checkout/CheckoutAuthTest.php b/tests/Feature/Checkout/CheckoutAuthTest.php index 9c17132..fa0fe00 100644 --- a/tests/Feature/Checkout/CheckoutAuthTest.php +++ b/tests/Feature/Checkout/CheckoutAuthTest.php @@ -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'), [ diff --git a/tests/Feature/Checkout/CheckoutFreeActivationTest.php b/tests/Feature/Checkout/CheckoutFreeActivationTest.php new file mode 100644 index 0000000..8d5d52b --- /dev/null +++ b/tests/Feature/Checkout/CheckoutFreeActivationTest.php @@ -0,0 +1,91 @@ +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, + ]); + } +} diff --git a/tests/Feature/Checkout/CheckoutSessionStatusTest.php b/tests/Feature/Checkout/CheckoutSessionStatusTest.php new file mode 100644 index 0000000..76cd6c4 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutSessionStatusTest.php @@ -0,0 +1,57 @@ +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(); + } +} diff --git a/tests/Feature/Packages/PackageSoftDeleteTest.php b/tests/Feature/Packages/PackageSoftDeleteTest.php index f167807..087bb0f 100644 --- a/tests/Feature/Packages/PackageSoftDeleteTest.php +++ b/tests/Feature/Packages/PackageSoftDeleteTest.php @@ -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, ], diff --git a/tests/Feature/PaddleCheckoutControllerTest.php b/tests/Feature/PaddleCheckoutControllerTest.php index 4b8f1fe..e036377 100644 --- a/tests/Feature/PaddleCheckoutControllerTest.php +++ b/tests/Feature/PaddleCheckoutControllerTest.php @@ -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, diff --git a/tests/Feature/PaddleWebhookControllerTest.php b/tests/Feature/PaddleWebhookControllerTest.php index 430b089..68f4e8a 100644 --- a/tests/Feature/PaddleWebhookControllerTest.php +++ b/tests/Feature/PaddleWebhookControllerTest.php @@ -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, ], diff --git a/tests/Feature/Tenant/EventAddonWebhookTest.php b/tests/Feature/Tenant/EventAddonWebhookTest.php index 3b2493a..f1d0303 100644 --- a/tests/Feature/Tenant/EventAddonWebhookTest.php +++ b/tests/Feature/Tenant/EventAddonWebhookTest.php @@ -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', ], diff --git a/tests/Unit/GiftVoucherCheckoutServiceTest.php b/tests/Unit/GiftVoucherCheckoutServiceTest.php index ea832c5..e395ae7 100644 --- a/tests/Unit/GiftVoucherCheckoutServiceTest.php +++ b/tests/Unit/GiftVoucherCheckoutServiceTest.php @@ -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']]); diff --git a/tests/Unit/GiftVoucherServiceTest.php b/tests/Unit/GiftVoucherServiceTest.php index 08a7dde..b1863c4 100644 --- a/tests/Unit/GiftVoucherServiceTest.php +++ b/tests/Unit/GiftVoucherServiceTest.php @@ -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', diff --git a/tests/Unit/PaddleCheckoutServiceTest.php b/tests/Unit/PaddleCheckoutServiceTest.php new file mode 100644 index 0000000..769303a --- /dev/null +++ b/tests/Unit/PaddleCheckoutServiceTest.php @@ -0,0 +1,62 @@ +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']); + } +} diff --git a/tests/ui/purchase/standard-package-checkout.test.ts b/tests/ui/purchase/standard-package-checkout.test.ts index d250059..6aba39e 100644 --- a/tests/ui/purchase/standard-package-checkout.test.ts +++ b/tests/ui/purchase/standard-package-checkout.test.ts @@ -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 })