Updated checkout to wait for backend confirmation before advancing, added a “Processing payment…” state with retry/ refresh fallback, and now use Paddle totals/currency for purchase records + confirmation emails (with new email translations).
This commit is contained in:
@@ -2,21 +2,27 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Checkout\CheckoutFreeActivationRequest;
|
||||
use App\Http\Requests\Checkout\CheckoutLoginRequest;
|
||||
use App\Http\Requests\Checkout\CheckoutRegisterRequest;
|
||||
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||
use App\Mail\Welcome;
|
||||
use App\Models\AbandonedCheckout;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class CheckoutController extends Controller
|
||||
@@ -61,35 +67,15 @@ class CheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function register(CheckoutRegisterRequest $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|email|max:255|unique:users,email',
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'address' => 'required|string|max:500',
|
||||
'phone' => 'required|string|max:255',
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'terms' => 'required|accepted',
|
||||
'privacy_consent' => 'required|accepted',
|
||||
'locale' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
$validated = $validator->validated();
|
||||
$user = DB::transaction(function () use ($request, $package, $validated) {
|
||||
$validated = $request->validated();
|
||||
$package = Package::findOrFail($validated['package_id']);
|
||||
$user = DB::transaction(function () use ($package, $validated) {
|
||||
|
||||
// User erstellen
|
||||
$user = User::create([
|
||||
'email' => $request->email,
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
@@ -97,7 +83,7 @@ class CheckoutController extends Controller
|
||||
'address' => $validated['address'],
|
||||
'phone' => $validated['phone'],
|
||||
'preferred_locale' => $validated['locale'] ?? null,
|
||||
'password' => Hash::make($request->password),
|
||||
'password' => Hash::make($validated['password']),
|
||||
'pending_purchase' => true,
|
||||
]);
|
||||
|
||||
@@ -171,28 +157,21 @@ class CheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
public function login(CheckoutLoginRequest $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'identifier' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
'remember' => 'boolean',
|
||||
'locale' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
$validated = $request->validated();
|
||||
$packageId = $validated['package_id'] ?? $request->session()->get('selected_package_id');
|
||||
if ($packageId) {
|
||||
$request->session()->put('selected_package_id', $packageId);
|
||||
}
|
||||
|
||||
$packageId = $request->session()->get('selected_package_id');
|
||||
|
||||
// Custom Auth für Identifier (E-Mail oder Username)
|
||||
$identifier = $request->identifier;
|
||||
$identifier = $validated['identifier'];
|
||||
$user = User::where('email', $identifier)
|
||||
->orWhere('username', $identifier)
|
||||
->first();
|
||||
|
||||
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||
if (! $user || ! Hash::check($validated['password'], $user->password)) {
|
||||
return response()->json([
|
||||
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']],
|
||||
], 422);
|
||||
@@ -220,6 +199,74 @@ class CheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function activateFree(
|
||||
CheckoutFreeActivationRequest $request,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
): JsonResponse {
|
||||
$validated = $request->validated();
|
||||
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail($validated['package_id']);
|
||||
|
||||
if ($package->price > 0) {
|
||||
return response()->json([
|
||||
'message' => 'Package is not eligible for free activation.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$requiresWaiver = (bool) ($package->activates_immediately ?? true);
|
||||
|
||||
if ($requiresWaiver && ! $request->boolean('accepted_waiver')) {
|
||||
return response()->json([
|
||||
'errors' => [
|
||||
'accepted_waiver' => ['Ein sofortiger Beginn der digitalen Dienstleistung erfordert Ihre ausdrückliche Zustimmung.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$session = $sessions->createOrResume($user, $package, [
|
||||
'tenant' => $user->tenant,
|
||||
'locale' => $validated['locale'] ?? null,
|
||||
]);
|
||||
|
||||
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
|
||||
|
||||
$now = now();
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $now,
|
||||
'accepted_privacy_at' => $now,
|
||||
'accepted_withdrawal_notice_at' => $now,
|
||||
'digital_content_waiver_at' => $requiresWaiver && $request->boolean('accepted_waiver') ? $now : null,
|
||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||
])->save();
|
||||
|
||||
$assignment->finalise($session, [
|
||||
'provider' => CheckoutSession::PROVIDER_FREE,
|
||||
]);
|
||||
|
||||
$sessions->markCompleted($session, $now);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'completed',
|
||||
'checkout_session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sessionStatus(
|
||||
CheckoutSessionStatusRequest $request,
|
||||
CheckoutSession $session,
|
||||
): JsonResponse {
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function trackAbandonedCheckout(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -21,17 +21,9 @@ class PaddleCheckoutController extends Controller
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): JsonResponse
|
||||
public function create(PaddleCheckoutRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
'accepted_waiver' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
$data = $request->validated();
|
||||
|
||||
$user = Auth::user();
|
||||
$tenant = $user?->tenant;
|
||||
@@ -89,6 +81,7 @@ class PaddleCheckoutController extends Controller
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'checkout_session_id' => $session->id,
|
||||
'mode' => 'inline',
|
||||
'items' => [
|
||||
[
|
||||
@@ -133,7 +126,9 @@ class PaddleCheckoutController extends Controller
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return response()->json($checkout);
|
||||
return response()->json(array_merge($checkout, [
|
||||
'checkout_session_id' => $session->id,
|
||||
]));
|
||||
}
|
||||
|
||||
protected function resolveLegalVersion(): string
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Services\Checkout\CheckoutWebhookService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TestCheckoutController extends Controller
|
||||
{
|
||||
@@ -89,7 +88,7 @@ class TestCheckoutController extends Controller
|
||||
'data' => array_filter([
|
||||
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
|
||||
'status' => $validated['status'] ?? 'completed',
|
||||
'metadata' => $metadata,
|
||||
'custom_data' => $metadata,
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
]),
|
||||
];
|
||||
|
||||
42
app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php
Normal file
42
app/Http/Requests/Checkout/CheckoutFreeActivationRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckoutFreeActivationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
'accepted_waiver' => ['nullable', 'boolean'],
|
||||
'locale' => ['nullable', 'string', 'max:10'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
'accepted_terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Checkout/CheckoutLoginRequest.php
Normal file
44
app/Http/Requests/Checkout/CheckoutLoginRequest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckoutLoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'identifier' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
'remember' => ['nullable', 'boolean'],
|
||||
'locale' => ['nullable', 'string', 'max:10'],
|
||||
'package_id' => ['nullable', 'exists:packages,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'identifier.required' => 'Bitte gib deine E-Mail-Adresse oder deinen Benutzernamen an.',
|
||||
'password.required' => 'Bitte gib dein Passwort an.',
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Http/Requests/Checkout/CheckoutRegisterRequest.php
Normal file
54
app/Http/Requests/Checkout/CheckoutRegisterRequest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class CheckoutRegisterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users,username'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
'address' => ['required', 'string', 'max:500'],
|
||||
'phone' => ['required', 'string', 'max:255'],
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'terms' => ['required', 'accepted'],
|
||||
'privacy_consent' => ['required', 'accepted'],
|
||||
'locale' => ['nullable', 'string', 'max:10'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.unique' => 'Diese E-Mail-Adresse wird bereits verwendet.',
|
||||
'username.unique' => 'Dieser Benutzername ist bereits vergeben.',
|
||||
'password.confirmed' => 'Die Passwortbestätigung stimmt nicht überein.',
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
'terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
||||
'privacy_consent.accepted' => 'Bitte akzeptiere die Datenschutzerklärung.',
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/Checkout/CheckoutSessionStatusRequest.php
Normal file
51
app/Http/Requests/Checkout/CheckoutSessionStatusRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckoutSessionStatusRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$session = $this->route('session');
|
||||
|
||||
if (! $session instanceof CheckoutSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->user();
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $session->user_id === (int) $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'session.required' => 'Checkout-Session fehlt.',
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Http/Requests/Paddle/PaddleCheckoutRequest.php
Normal file
45
app/Http/Requests/Paddle/PaddleCheckoutRequest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Paddle;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PaddleCheckoutRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
'accepted_waiver' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
'accepted_terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class PurchaseConfirmation extends Mailable
|
||||
'user' => $this->purchase->tenant->user,
|
||||
'package' => $this->purchase->package,
|
||||
'packageName' => $this->localizedPackageName(),
|
||||
'priceFormatted' => $this->formattedTotal(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -49,4 +50,45 @@ class PurchaseConfirmation extends Mailable
|
||||
|
||||
return optional($this->purchase->package)->getNameForLocale($locale) ?? '';
|
||||
}
|
||||
|
||||
private function formattedTotal(): string
|
||||
{
|
||||
$totals = $this->purchase->metadata['paddle_totals'] ?? [];
|
||||
$currency = $totals['currency']
|
||||
?? $this->purchase->metadata['currency']
|
||||
?? $this->purchase->package?->currency
|
||||
?? 'EUR';
|
||||
$amount = array_key_exists('total', $totals) ? (float) $totals['total'] : (float) $this->purchase->price;
|
||||
|
||||
$locale = $this->locale ?? app()->getLocale();
|
||||
$formatter = class_exists(\NumberFormatter::class)
|
||||
? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY)
|
||||
: null;
|
||||
|
||||
if ($formatter) {
|
||||
$formatted = $formatter->formatCurrency($amount, $currency);
|
||||
if ($formatted !== false) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
$symbol = match ($currency) {
|
||||
'EUR' => '€',
|
||||
'USD' => '$',
|
||||
default => $currency,
|
||||
};
|
||||
|
||||
return number_format($amount, 2, ',', '.').' '.$symbol;
|
||||
}
|
||||
|
||||
private function mapLocale(string $locale): string
|
||||
{
|
||||
$normalized = strtolower(str_replace('_', '-', $locale));
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($normalized, 'de') => 'de_DE',
|
||||
str_starts_with($normalized, 'en') => 'en_US',
|
||||
default => 'de_DE',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Checkout;
|
||||
|
||||
use App\Mail\PurchaseConfirmation;
|
||||
use App\Mail\Welcome;
|
||||
use App\Models\AbandonedCheckout;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
@@ -13,6 +12,7 @@ use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Ops\PurchaseCreated;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -69,6 +69,10 @@ class CheckoutAssignmentService
|
||||
?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null)
|
||||
?? CheckoutSession::PROVIDER_FREE;
|
||||
|
||||
$totals = $this->resolvePaddleTotals($session, $options['payload'] ?? []);
|
||||
$currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR';
|
||||
$price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total;
|
||||
|
||||
$purchase = PackagePurchase::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -77,30 +81,39 @@ class CheckoutAssignmentService
|
||||
],
|
||||
[
|
||||
'provider' => $providerName,
|
||||
'price' => $session->amount_total,
|
||||
'price' => round($price, 2),
|
||||
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
|
||||
'purchased_at' => now(),
|
||||
'metadata' => array_filter([
|
||||
'payload' => $options['payload'] ?? null,
|
||||
'checkout_session_id' => $session->id,
|
||||
'consents' => $consents ?: null,
|
||||
]),
|
||||
'paddle_totals' => $totals !== [] ? $totals : null,
|
||||
'currency' => $currency,
|
||||
], static fn ($value) => $value !== null && $value !== ''),
|
||||
]
|
||||
);
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
$tenantPackage = TenantPackage::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
[
|
||||
'price' => $session->amount_total,
|
||||
'price' => round($price, 2),
|
||||
'active' => true,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => $this->resolveExpiry($package, $tenant),
|
||||
]
|
||||
);
|
||||
|
||||
if ($package->type !== 'reseller') {
|
||||
$tenant->forceFill([
|
||||
'subscription_status' => 'active',
|
||||
'subscription_expires_at' => $tenantPackage->expires_at,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($user && $user->pending_purchase) {
|
||||
$this->activateUser($user);
|
||||
}
|
||||
@@ -108,10 +121,6 @@ class CheckoutAssignmentService
|
||||
if ($user) {
|
||||
$mailLocale = $user->preferred_locale ?? app()->getLocale();
|
||||
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
->queue(new Welcome($user));
|
||||
|
||||
if ($purchase->wasRecentlyCreated) {
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
@@ -196,4 +205,63 @@ class CheckoutAssignmentService
|
||||
'pending_purchase' => false,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function resolvePaddleTotals(CheckoutSession $session, array $payload): array
|
||||
{
|
||||
$metadataTotals = $session->provider_metadata['paddle_totals'] ?? null;
|
||||
|
||||
if (is_array($metadataTotals) && $metadataTotals !== []) {
|
||||
return $metadataTotals;
|
||||
}
|
||||
|
||||
$totals = Arr::get($payload, 'details.totals', Arr::get($payload, 'totals', []));
|
||||
if (! is_array($totals) || $totals === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? Arr::get($payload, 'currency_code')
|
||||
?? Arr::get($totals, 'currency')
|
||||
?? Arr::get($payload, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
|
||||
return array_filter([
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
protected function convertMinorAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ class CheckoutWebhookService
|
||||
return true;
|
||||
|
||||
case 'transaction.completed':
|
||||
$this->syncSessionTotals($session, $data);
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paddle_status' => $status ?: 'completed',
|
||||
@@ -146,6 +147,87 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
protected function syncSessionTotals(CheckoutSession $session, array $data): void
|
||||
{
|
||||
$totals = $this->normalizePaddleTotals($data);
|
||||
|
||||
if ($totals === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
if (array_key_exists('subtotal', $totals)) {
|
||||
$updates['amount_subtotal'] = $totals['subtotal'];
|
||||
}
|
||||
|
||||
if (array_key_exists('discount', $totals)) {
|
||||
$updates['amount_discount'] = $totals['discount'];
|
||||
}
|
||||
|
||||
if (array_key_exists('total', $totals)) {
|
||||
$updates['amount_total'] = $totals['total'];
|
||||
}
|
||||
|
||||
if (! empty($totals['currency'])) {
|
||||
$updates['currency'] = $totals['currency'];
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$session->forceFill($updates)->save();
|
||||
}
|
||||
|
||||
$this->mergeProviderMetadata($session, [
|
||||
'paddle_totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function normalizePaddleTotals(array $data): array
|
||||
{
|
||||
$totals = Arr::get($data, 'details.totals', Arr::get($data, 'totals', []));
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? $data['currency_code'] ?? Arr::get($totals, 'currency') ?? Arr::get($data, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
|
||||
return array_filter([
|
||||
'currency' => $currency ? strtoupper((string) $currency) : null,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
protected function convertMinorAmount(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value) && isset($value['amount'])) {
|
||||
$value = $value['amount'];
|
||||
}
|
||||
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
|
||||
protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool
|
||||
{
|
||||
$subscriptionId = $data['id'] ?? null;
|
||||
@@ -154,8 +236,8 @@ class CheckoutWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $metadata, $subscriptionId);
|
||||
$customData = $this->extractCustomData($data);
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $tenant) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [
|
||||
@@ -165,7 +247,7 @@ class CheckoutWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$package = $this->resolvePackageFromSubscription($data, $metadata, $subscriptionId);
|
||||
$package = $this->resolvePackageFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $package) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [
|
||||
@@ -317,7 +399,7 @@ class CheckoutWebhookService
|
||||
|
||||
protected function isGiftVoucherEvent(array $data): bool
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($data);
|
||||
|
||||
$type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null;
|
||||
|
||||
@@ -336,7 +418,7 @@ class CheckoutWebhookService
|
||||
|
||||
protected function locatePaddleSession(array $data): ?CheckoutSession
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($data);
|
||||
|
||||
if (is_array($metadata)) {
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
@@ -372,4 +454,27 @@ class CheckoutWebhookService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractCustomData(array $data): array
|
||||
{
|
||||
$customData = [];
|
||||
|
||||
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
|
||||
$customData = $data['custom_data'];
|
||||
}
|
||||
|
||||
if (isset($data['customData']) && is_array($data['customData'])) {
|
||||
$customData = array_merge($customData, $data['customData']);
|
||||
}
|
||||
|
||||
if (isset($data['metadata']) && is_array($data['metadata'])) {
|
||||
$customData = array_merge($customData, $data['metadata']);
|
||||
}
|
||||
|
||||
return $customData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class GiftVoucherCheckoutService
|
||||
],
|
||||
],
|
||||
'customer_email' => $data['purchaser_email'],
|
||||
'metadata' => array_filter([
|
||||
'custom_data' => array_filter([
|
||||
'type' => 'gift_voucher',
|
||||
'tier_key' => $tier['key'],
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
|
||||
@@ -4,20 +4,20 @@ namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\NotifyGiftVoucherReminder;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Mail\GiftVoucherIssued;
|
||||
use App\Jobs\NotifyGiftVoucherReminder;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class GiftVoucherService
|
||||
{
|
||||
@@ -28,7 +28,7 @@ class GiftVoucherService
|
||||
*/
|
||||
public function issueFromPaddle(array $payload): GiftVoucher
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($payload);
|
||||
$priceId = $this->resolvePriceId($payload);
|
||||
$amount = $this->resolveAmount($payload);
|
||||
$currency = Str::upper($this->resolveCurrency($payload));
|
||||
@@ -193,7 +193,7 @@ class GiftVoucherService
|
||||
|
||||
protected function resolvePriceId(array $payload): ?string
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
$metadata = $this->extractCustomData($payload);
|
||||
|
||||
if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) {
|
||||
return $metadata['paddle_price_id'];
|
||||
@@ -242,6 +242,29 @@ class GiftVoucherService
|
||||
?? 'EUR';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractCustomData(array $payload): array
|
||||
{
|
||||
$customData = [];
|
||||
|
||||
if (isset($payload['custom_data']) && is_array($payload['custom_data'])) {
|
||||
$customData = $payload['custom_data'];
|
||||
}
|
||||
|
||||
if (isset($payload['customData']) && is_array($payload['customData'])) {
|
||||
$customData = array_merge($customData, $payload['customData']);
|
||||
}
|
||||
|
||||
if (isset($payload['metadata']) && is_array($payload['metadata'])) {
|
||||
$customData = array_merge($customData, $payload['metadata']);
|
||||
}
|
||||
|
||||
return $customData;
|
||||
}
|
||||
|
||||
protected function generateCode(): string
|
||||
{
|
||||
return 'GIFT-'.Str::upper(Str::random(8));
|
||||
|
||||
@@ -16,7 +16,7 @@ class PaddleCheckoutService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_id?: string|null, metadata?: array} $options
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_id?: string|null, metadata?: array, custom_data?: array} $options
|
||||
*/
|
||||
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
|
||||
{
|
||||
@@ -31,7 +31,11 @@ class PaddleCheckoutService
|
||||
'highlight' => $package->slug,
|
||||
]);
|
||||
|
||||
$metadata = $this->buildMetadata($tenant, $package, $options['metadata'] ?? []);
|
||||
$customData = $this->buildMetadata(
|
||||
$tenant,
|
||||
$package,
|
||||
array_merge($options['metadata'] ?? [], $options['custom_data'] ?? [])
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'customer_id' => $customerId,
|
||||
@@ -41,7 +45,7 @@ class PaddleCheckoutService
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'metadata' => $metadata,
|
||||
'custom_data' => $customData,
|
||||
'success_url' => $successUrl,
|
||||
'cancel_url' => $returnUrl,
|
||||
];
|
||||
|
||||
@@ -28,6 +28,14 @@ class PaddleSubscriptionService
|
||||
*/
|
||||
public function metadata(array $subscription): array
|
||||
{
|
||||
return Arr::get($subscription, 'data.metadata', []);
|
||||
$customData = Arr::get($subscription, 'data.custom_data');
|
||||
|
||||
if (is_array($customData)) {
|
||||
return $customData;
|
||||
}
|
||||
|
||||
$metadata = Arr::get($subscription, 'data.metadata');
|
||||
|
||||
return is_array($metadata) ? $metadata : [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user