536 lines
20 KiB
PHP
536 lines
20 KiB
PHP
<?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 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<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);
|
||
}
|
||
}
|