10.April 2026

This commit is contained in:
Kevin Adametz 2026-04-10 17:15:27 +02:00
parent a00c42e770
commit f58c709945
208 changed files with 19280 additions and 2914 deletions

View file

@ -0,0 +1,127 @@
<?php
namespace App\Services\SyS;
use App\Models\ShoppingOrder;
use App\Models\UserAboOrder;
class AboOrdersOverview
{
/**
* Payone-/Shop-Zahlungsstatus: tatsächlich eingezogen.
*
* @var list<string>
*/
private const SUCCESS_TXACTIONS = ['paid', 'extern_paid', 'invoice_paid'];
public static function show()
{
$filter = request('filter', 'all');
$aboOrders = UserAboOrder::with([
'user_abo',
'user_abo.user',
'user_abo.user.account',
'shopping_order',
'shopping_order.shopping_user',
'shopping_order.shopping_payments',
])
->whereHas('shopping_order')
->when($filter === 'berater', fn ($q) => $q->whereHas('user_abo', fn ($q) => $q->where('is_for', 'me')))
->when($filter === 'kunde', fn ($q) => $q->whereHas('user_abo', fn ($q) => $q->where('is_for', '!=', 'me')))
->orderByDesc('created_at')
->get();
$summary = [
'total_orders' => $aboOrders->count(),
'total_diff' => 0.0,
'affected_orders' => 0,
];
$rows = [];
foreach ($aboOrders as $aboOrder) {
$order = $aboOrder->shopping_order;
if (! $order) {
continue;
}
$subtotalWs = (float) $order->subtotal_ws;
$totalShipping = (float) $order->total_shipping;
$tax = (float) $order->tax;
$expectedCents = (int) round($totalShipping * 100);
$actualCents = self::actualChargedCentsFromPayments($order);
$actualEur = $actualCents !== null ? round($actualCents / 100, 2) : null;
$diff = ($actualCents !== null)
? round(($expectedCents - $actualCents) / 100, 2)
: null;
if ($diff !== null && abs($diff) <= 0.01) {
$diff = 0;
}
$payments = $order->shopping_payments;
$paymentTxSummary = $payments->isEmpty()
? null
: $payments->pluck('txaction')->filter()->unique()->implode(', ');
$user = $aboOrder->user_abo->user ?? null;
$rows[] = [
'abo_order_id' => $aboOrder->id,
'abo_id' => $aboOrder->user_abo_id,
'order_id' => '<a href='.route('admin_sales_customers_detail', [$aboOrder->shopping_order_id]).'>'.$aboOrder->shopping_order_id.'</a>',
'user_id' => $user->id ?? null,
'user_name' => $aboOrder->shopping_order->shopping_user ? ($aboOrder->shopping_order->shopping_user->billing_firstname ?? '').' '.($aboOrder->shopping_order->shopping_user->billing_lastname ?? '') : '-',
'user_email' => $aboOrder->shopping_order->shopping_user ? $aboOrder->shopping_order->shopping_user->billing_email ?? '-' : '-',
'is_for' => $aboOrder->user_abo->is_for ?? '-',
'subtotal_ws' => $subtotalWs,
'tax' => $tax,
'total_shipping' => $totalShipping,
'actual_charged_eur' => $actualEur,
'payment_count' => $payments->count(),
'payment_txactions' => $paymentTxSummary,
'diff' => $diff,
'status' => $aboOrder->status,
'paid' => $aboOrder->paid,
'txaction' => $order->txaction,
'created_at' => $aboOrder->created_at,
];
if ($diff !== null && abs($diff) >= 0.01) {
$summary['total_diff'] += $diff;
$summary['affected_orders']++;
}
}
$summary['total_diff'] = round($summary['total_diff'], 2);
return view('sys.tools.abo-orders-overview', [
'rows' => $rows,
'summary' => $summary,
'filter' => $filter,
]);
}
/**
* Summiert erfolgreiche Abbuchungen aus `shopping_payments` (Cent).
* Kein Treffer bei erfolgreichen Status null (kein belastbarer Eingang).
*/
public static function actualChargedCentsFromPayments(ShoppingOrder $order): ?int
{
$payments = $order->shopping_payments;
if ($payments === null || $payments->isEmpty()) {
return null;
}
$successful = $payments->filter(
fn ($p) => in_array($p->txaction, self::SUCCESS_TXACTIONS, true)
);
$sum = (int) $successful->sum('amount');
return $sum > 0 ? $sum : null;
}
}

View file

@ -0,0 +1,536 @@
<?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);
}
}