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:
Codex Agent
2025-12-22 09:06:48 +01:00
parent 41d29eb7d3
commit 84234bfb8e
36 changed files with 1742 additions and 187 deletions

View File

@@ -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([

View File

@@ -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

View File

@@ -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(),
]),
];

View 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.',
];
}
}

View 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.',
];
}
}

View 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.',
];
}
}

View 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.',
];
}
}

View 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.',
];
}
}

View File

@@ -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',
};
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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'],

View File

@@ -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));

View File

@@ -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,
];

View File

@@ -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
View 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
View 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',
],
],
];

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
}),
});

View File

@@ -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;

View File

@@ -206,6 +206,7 @@ export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml, googleProfile,
<LoginForm
locale={locale}
onSuccess={handleLoginSuccess}
packageId={selectedPackage?.id ?? null}
/>
)}
</div>

View File

@@ -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;
}
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,12 +416,7 @@ 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,
@@ -319,17 +425,14 @@ export const PaymentStep: React.FC = () => {
theme: 'light',
locale: paddleLocale,
},
customData: {
package_id: String(selectedPackage.id),
locale: paddleLocale,
accepted_terms: acceptedTerms ? '1' : '0',
accepted_waiver: requiresImmediateWaiver && acceptedWaiver ? '1' : '0',
},
};
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" />

View File

@@ -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>

View File

@@ -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');
});

View File

@@ -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'), [

View 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,
]);
}
}

View 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();
}
}

View File

@@ -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,
],

View File

@@ -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,

View File

@@ -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,
],

View File

@@ -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',
],

View File

@@ -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']]);

View File

@@ -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',

View 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']);
}
}

View File

@@ -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 })