mivita/app/Services/SyS/PayoneCallbackTestbench.php
2026-04-10 17:15:27 +02:00

536 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\SyS;
use App\Console\Commands\UserMakeAboOrder;
use App\Models\PaymentTransaction;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserShop;
use App\Services\AboHelper;
use Carbon\Carbon;
use Illuminate\Console\Command as ConsoleCommand;
use Illuminate\Console\OutputStyle;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Str;
use ReflectionMethod;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Throwable;
class PayoneCallbackTestbench
{
public static function show()
{
self::ensureAllowed();
return view('sys.tools.payone-callback-testbench', [
'fixture' => 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<string, string>
*/
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<string, string>
*/
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 23) 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<string, mixed>
*/
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);
}
}