session('payone_testbench_fixture'), 'simulateResult' => session('payone_testbench_simulate'), 'checkoutSuccess' => session('payone_testbench_checkout_success'), 'userAboId' => session('payone_testbench_user_abo_id'), 'cronRenewal' => session('payone_testbench_cron_renewal'), 'cronRenewalOrderId' => session('payone_testbench_cron_renewal_order_id'), ]); } public static function store() { self::ensureAllowed(); $action = request('action'); if ($action === 'create_fixture') { return self::createFixture(); } if ($action === 'simulate_paid') { return self::simulatePaidCallback(); } if ($action === 'simulate_checkout_success') { return self::simulateCheckoutSuccess(); } if ($action === 'simulate_cron_renewal') { return self::simulateCronRenewal(); } if ($action === 'clear_fixture') { session()->forget([ 'payone_testbench_fixture', 'payone_testbench_simulate', 'payone_testbench_checkout_success', 'payone_testbench_user_abo_id', 'payone_testbench_cron_renewal', 'payone_testbench_cron_renewal_order_id', ]); return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); } return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Unbekannte Aktion.'); } /** * Payload wie Payone ihn an die Status-URL sendet (Route: api.{domain}/payment/status). * * @return array */ public static function buildPayoneCallbackPayload(ShoppingOrder $order, ShoppingPayment $payment, ?int $txid = null): array { $txid = $txid ?? random_int(100_000_000, 999_999_999); $price = number_format(round($payment->amount / 100, 2), 2, '.', ''); return [ 'key' => (string) config('payone.defaults.key'), 'param' => (string) $order->id, 'userid' => '999999999', 'txid' => (string) $txid, 'reference' => $payment->reference, 'price' => $price, 'txaction' => 'paid', 'mode' => $payment->mode ?? 'test', 'clearingtype' => $payment->clearingtype, ]; } /** * Vollständige URL der Payone-Server-zu-Server-Route (z. B. http://api.mivita.test/payment/status). */ public static function paymentStatusUrl(): string { return route('api.payment_status', [], true); } private static function ensureAllowed(): void { if (app()->isProduction()) { abort(403, 'Payone-Testbench ist in Production deaktiviert.'); } } private static function createFixture() { $validated = request()->validate([ 'amount_eur' => ['required', 'numeric', 'min:0.01', 'max:99999.99'], 'consultant_user_id' => ['required', 'integer', 'exists:users,id'], 'is_abo' => ['sometimes', 'boolean'], 'is_for_ot' => ['sometimes', 'boolean'], ]); $amountEur = round((float) $validated['amount_eur'], 2); $amountCents = (int) round($amountEur * 100); $isAbo = request()->boolean('is_abo'); $isFor = request()->boolean('is_for_ot') ? 'ot' : 'me'; $consultantId = (int) $validated['consultant_user_id']; session()->forget(['payone_testbench_simulate', 'payone_testbench_checkout_success']); $country = ShippingCountry::query()->first(); if (! $country) { abort(500, 'Kein Eintrag in shipping_countries – bitte Stammdaten anlegen.'); } $userShop = UserShop::query()->first(); if (! $userShop) { abort(500, 'Kein user_shops Eintrag – bitte Shop anlegen.'); } $fixture = DB::transaction(function () use ($amountEur, $amountCents, $isAbo, $isFor, $country, $userShop, $consultantId) { $email = 'payone-bench-'.Str::lower(Str::random(8)).'@example.test'; if ($isFor === 'me') { $shoppingUserAttrs = [ 'billing_firstname' => 'Bench', 'billing_lastname' => 'Payone', 'billing_email' => $email, 'billing_country_id' => $country->id, 'shipping_country_id' => $country->id, 'is_for' => $isFor, 'is_from' => 'user_order', 'auth_user_id' => $consultantId, 'member_id' => null, ]; $orderAttrs = [ 'auth_user_id' => $consultantId, 'member_id' => null, ]; } else { $shoppingUserAttrs = [ 'billing_firstname' => 'Bench', 'billing_lastname' => 'Payone', 'billing_email' => $email, 'billing_country_id' => $country->id, 'shipping_country_id' => $country->id, 'is_for' => $isFor, 'is_from' => 'user_order', 'auth_user_id' => null, 'member_id' => $consultantId, ]; $orderAttrs = [ 'auth_user_id' => null, 'member_id' => $consultantId, ]; } $shoppingUser = ShoppingUser::create($shoppingUserAttrs); $order = ShoppingOrder::create(array_merge([ 'shopping_user_id' => $shoppingUser->id, 'country_id' => $country->id, 'language' => app()->getLocale(), 'user_shop_id' => $userShop->id, 'payment_for' => $shoppingUser->getOrderPaymentFor(), 'total' => $amountEur, 'subtotal' => $amountEur, 'shipping' => 0, 'shipping_net' => 0, 'subtotal_ws' => $amountEur, 'tax' => 0, 'total_shipping' => $amountEur, 'points' => 0, 'weight' => 0, 'paid' => false, 'is_abo' => $isAbo, 'abo_interval' => $isAbo ? 30 : 0, 'txaction' => 'prev', 'mode' => 'test', ], $orderAttrs)); $reference = self::generatePaymentReference(); $payment = ShoppingPayment::create([ 'shopping_order_id' => $order->id, 'clearingtype' => 'wlt', 'wallettype' => 'PPE', 'onlinebanktransfertype' => '', 'reference' => $reference, 'amount' => $amountCents, 'currency' => 'EUR', 'txaction' => null, 'mode' => 'test', 'is_abo' => $isAbo, 'abo_interval' => $isAbo ? ($order->abo_interval ?? 0) : 0, ]); return [ 'shopping_order_id' => $order->id, 'shopping_payment_id' => $payment->id, 'reference' => $reference, 'amount_eur' => $amountEur, 'amount_cents' => $amountCents, 'is_abo' => $isAbo, 'is_for' => $isFor, 'consultant_user_id' => $consultantId, 'assignment_note' => $isFor === 'me' ? 'Berater: auth_user_id = Berater-ID, member_id leer' : 'Kunde: auth_user_id leer, member_id = Berater-ID (Zuordnung / Provision)', 'api_url' => self::paymentStatusUrl(), 'curl' => self::buildCurlExample(self::paymentStatusUrl(), self::buildPayoneCallbackPayload($order->fresh(), $payment->fresh())), ]; }); session()->put('payone_testbench_fixture', $fixture); return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); } /** * Entspricht dem Browser-Erfolg nach Zahlung: {@see CheckoutController::handleSuccessfulTransaction} * und {@see CheckoutController::transactionApproved} → {@see AboHelper::createNewAbo}. */ private static function simulateCheckoutSuccess() { $validated = request()->validate([ 'shopping_order_id' => ['required', 'integer', 'exists:shopping_orders,id'], ]); $order = ShoppingOrder::query() ->with(['shopping_order_items', 'shopping_user', 'shopping_payments']) ->findOrFail($validated['shopping_order_id']); $payment = $order->shopping_payments->last(); if (! $payment) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Keine ShoppingPayment zu dieser Bestellung.'); } if (! $order->is_abo || (int) $order->abo_interval <= 0) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Bestellung ist kein Abo (is_abo / abo_interval).'); } if (UserAboOrder::query()->where('shopping_order_id', $order->id)->exists()) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Für diese Bestellung existiert bereits ein UserAboOrder – Abo wurde bereits angelegt.'); } $payment->load('payment_transactions'); if ($payment->payment_transactions->isEmpty()) { PaymentTransaction::create([ 'shopping_payment_id' => $payment->id, 'request' => 'transaction', 'txid' => random_int(1, 999_999_999), 'userid' => 999_999_999, 'status' => 'PAYONE', 'key' => (string) config('payone.defaults.key'), 'txaction' => 'paid', 'transmitted_data' => [], 'mode' => $payment->mode ?? 'test', ]); } try { AboHelper::createNewAbo($payment->fresh([ 'shopping_order.shopping_user', 'shopping_order.shopping_order_items', 'payment_transactions', ])); } catch (Throwable $e) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'createNewAbo: '.$e->getMessage()); } $userAboOrder = UserAboOrder::query() ->where('shopping_order_id', $order->id) ->with('user_abo') ->first(); session()->put('payone_testbench_checkout_success', [ 'user_abo_id' => $userAboOrder?->user_abo_id, 'user_abo_order_id' => $userAboOrder?->id, 'shopping_order_id' => $order->id, 'order_paid_after' => (bool) $order->fresh()->paid, 'hint' => 'Erstbestellung: Abo-Stammdaten wie nach Checkout-Redirect. Die Bestätigung (paid, setAboActive, Incentive) folgt im nächsten Schritt über die Payone-API.', ]); if ($userAboOrder?->user_abo_id) { session()->put('payone_testbench_user_abo_id', $userAboOrder->user_abo_id); } return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); } /** * @return array */ private static function buildCurlExample(string $url, array $payload): string { $parts = []; foreach ($payload as $k => $v) { $parts[] = escapeshellarg($k).'='.escapeshellarg((string) $v); } return 'curl -X POST '.escapeshellarg($url).' -d '.implode(' -d ', $parts); } private static function generatePaymentReference(): string { return substr(str_replace('-', '', (string) Str::uuid()), 0, 16); } private static function simulatePaidCallback() { $validated = request()->validate([ 'shopping_order_id' => ['required', 'integer', 'exists:shopping_orders,id'], ]); $order = ShoppingOrder::query()->with('shopping_payments')->findOrFail($validated['shopping_order_id']); $payment = $order->shopping_payments->last(); if (! $payment) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Keine ShoppingPayment zu dieser Bestellung.'); } if ($order->is_abo && (int) $order->abo_interval > 0) { if (! UserAboOrder::query()->where('shopping_order_id', $order->id)->exists()) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Bei einer Abo-Erstbestellung zuerst Schritt 2 (Checkout: createNewAbo) ausführen, danach erst die Payone-API (paid). So existiert ein UserAboOrder für setAboActive und trackAboActivated.'); } } $payload = self::buildPayoneCallbackPayload($order, $payment); $request = Request::create(self::paymentStatusUrl(), 'POST', $payload); $response = app()->handle($request); $order->refresh(); $result = [ 'http_status' => $response->getStatusCode(), 'body' => $response->getContent(), 'order_paid' => (bool) $order->paid, 'order_txaction' => $order->txaction, 'payload' => $payload, 'hint' => 'Entspricht Payment::paymentStatusPaidAction: Abo bestätigen (setAboActive), ggf. Incentive trackAboActivated, Rechnung …', ]; session()->put('payone_testbench_simulate', $result); $userAbo = $order->getUserAbo(); if ($userAbo) { session()->put('payone_testbench_user_abo_id', $userAbo->id); } return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); } /** * Wie {@see UserMakeAboOrder::checkAbosToOrder}: next_date = heute und keine doppelte Verarbeitung am selben Tag. * Nur fuer Testbench (nicht Production). */ public static function prepareUserAboForCronRun(UserAbo $userAbo): void { self::ensureAllowed(); $today = Carbon::today()->format('Y-m-d'); DB::transaction(function () use ($userAbo, $today) { UserAboOrder::query() ->where('user_abo_id', $userAbo->id) ->whereDate('created_at', $today) ->delete(); $userAbo->update(['next_date' => $today]); }); } /** * Einmaliger Cron-Lauf: {@see UserMakeAboOrder::makeOrder} (Bestellung + Payone-Zahlung). * Danach ggf. Schritt 5: Payone-API (paid) fuer die Verlaengerungs-Bestellung. */ private static function simulateCronRenewal() { self::ensureAllowed(); $validated = request()->validate([ 'user_abo_id' => ['nullable', 'integer', 'exists:user_abos,id'], ]); $userAboId = $validated['user_abo_id'] ?? session('payone_testbench_user_abo_id'); if (! $userAboId) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Kein user_abo_id – zuerst Abo-Erstkauf (Schritte 2–3) abschließen oder ID eintragen.'); } $userAbo = UserAbo::query() ->with(['user_abo_items', 'shopping_user']) ->findOrFail($userAboId); AboHelper::ensureUserAboItemsFromLatestOrder($userAbo); $userAbo->refresh(); $userAbo->load(['user_abo_items', 'shopping_user']); if (! $userAbo->active || (int) $userAbo->status !== 2) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'UserAbo nicht aktiv oder nicht status 2 (abo_okay).'); } if ($userAbo->user_abo_items->isEmpty()) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Keine Abo-Artikel – Verlängerung nicht möglich.'); } self::prepareUserAboForCronRun($userAbo); $userAbo->refresh(); $command = new UserMakeAboOrder; self::bindNullConsoleOutput($command); $makeOrder = new ReflectionMethod(UserMakeAboOrder::class, 'makeOrder'); $makeOrder->setAccessible(true); try { /** @var ShoppingOrder|null $shoppingOrder */ $shoppingOrder = $makeOrder->invoke($command, $userAbo->fresh(['user_abo_items', 'shopping_user'])); } catch (Throwable $e) { return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) ->with('error', 'Cron makeOrder: '.$e->getMessage()); } if (! $shoppingOrder) { session()->put('payone_testbench_cron_renewal', [ 'success' => false, 'message' => 'makeOrder hat keine Bestellung zurückgegeben (typisch: makeShoppingOrder false, z. B. fehlende Referenz-Bestellung/user_shop_id, oder createShoppingUser liefert nichts).', 'diagnosis' => self::cronRenewalDiagnosis($userAbo->fresh(['user_abo_items', 'shopping_user'])), ]); session()->forget('payone_testbench_cron_renewal_order_id'); return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); } session()->put('payone_testbench_cron_renewal', [ 'success' => true, 'shopping_order_id' => $shoppingOrder->id, 'user_abo_id' => $userAbo->id, 'hint' => 'Entspricht user:make_abo_order / makeOrder. Für Rechnung und Incentive-Umsatz wie in Produktion: Schritt 5 (Payone paid) für diese Verlängerungs-Bestellung ausführen.', ]); session()->put('payone_testbench_cron_renewal_order_id', $shoppingOrder->id); return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); } /** * Ohne Log-Datei: Ursachen fuer fehlgeschlagenes makeOrder eingrenzen. * * @return array */ private static function cronRenewalDiagnosis(UserAbo $userAbo): array { $out = [ 'user_abo_items' => $userAbo->user_abo_items->count(), ]; $su = $userAbo->shopping_user; if (! $su) { $out['shopping_user'] = 'fehlt (UserAbo.shopping_user_id)'; return $out; } $out['shopping_user_id'] = $su->id; $out['shopping_orders_count'] = $su->shopping_orders()->count(); $ref = $su->shopping_orders()->orderByDesc('id')->first(); $out['reference_order_id'] = $ref?->id; $out['reference_user_shop_id'] = $ref?->user_shop_id; if ($ref && $ref->user_shop_id) { $shop = UserShop::withTrashed()->find($ref->user_shop_id); $out['user_shop_row_exists'] = $shop !== null; $out['user_shop_row_trashed'] = $shop !== null && $shop->trashed(); $out['user_shop_relation_loaded'] = $ref->user_shop !== null; } return $out; } /** * Ohne Artisan-Kontext ist $command->output null – info()/error() wuerden crashen. */ private static function bindNullConsoleOutput(ConsoleCommand $command): void { $command->setLaravel(app()); $input = new ArrayInput([]); $nullOutput = new NullOutput; $outputStyle = app()->make(OutputStyle::class, [ 'input' => $input, 'output' => $nullOutput, ]); $reflection = new \ReflectionClass($command); $property = $reflection->getProperty('output'); $property->setAccessible(true); $property->setValue($command, $outputStyle); } }