Compare commits
4 commits
ee04146217
...
0d4c0b640c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d4c0b640c | |||
| 321e7959da | |||
| 090debf1b4 | |||
| 8593dbe65d |
47 changed files with 4216 additions and 817 deletions
11
.env.example
11
.env.example
|
|
@ -87,3 +87,14 @@ DHL_DEBUG_MODE=false # true für detaillierte Debug-Informationen
|
|||
# Erlaubt Business-Commands an jedem Tag (nur für Test-Server!)
|
||||
# Auf Live-Server muss dies false sein oder weggelassen werden
|
||||
BUSINESS_FORCE_EXECUTE=false
|
||||
|
||||
# =============================================================================
|
||||
# PAYONE KONFIGURATION
|
||||
# =============================================================================
|
||||
PAYONE_URL=checkout.example.test
|
||||
PAYONE_TS=checkout.example.test/transaction/status
|
||||
# MD5-Hash des PAYONE-Portal-Keys (NICHT der Klartext-Key). Pflichtwert:
|
||||
# Fehlt er, schlägt der Application-Boot bewusst hart fehl.
|
||||
# Hash erzeugen (verdeckte Eingabe, schreibt optional direkt in die .env):
|
||||
# php artisan payone:hash-key --write
|
||||
PAYONE_KEY_HASH=
|
||||
|
|
|
|||
86
app/Console/Commands/PayoneHashKey.php
Normal file
86
app/Console/Commands/PayoneHashKey.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
use function Laravel\Prompts\password;
|
||||
|
||||
class PayoneHashKey extends Command
|
||||
{
|
||||
protected $signature = 'payone:hash-key
|
||||
{--write : Schreibt den erzeugten Hash als PAYONE_KEY_HASH in die .env}';
|
||||
|
||||
protected $description = 'Erzeugt aus dem PAYONE-Portal-Key (Klartext) den benötigten MD5-Hash für PAYONE_KEY_HASH';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->components->info('Der Klartext-Key wird nur flüchtig zur Hash-Erzeugung verwendet und nirgends gespeichert oder geloggt.');
|
||||
|
||||
$plainKey = trim((string) password(
|
||||
label: 'PAYONE-Portal-Key (Klartext, verdeckte Eingabe)',
|
||||
required: true,
|
||||
));
|
||||
|
||||
if ($plainKey === '') {
|
||||
$this->components->error('Es wurde kein Key eingegeben.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$hash = hash('md5', $plainKey);
|
||||
|
||||
$this->newLine();
|
||||
$this->components->twoColumnDetail('PAYONE_KEY_HASH', $hash);
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->option('write')) {
|
||||
$this->line(' Ohne <comment>--write</comment>: Wert manuell als <comment>PAYONE_KEY_HASH</comment> in die .env übernehmen.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! confirm("PAYONE_KEY_HASH in {$this->envPath()} schreiben/aktualisieren?", default: true)) {
|
||||
$this->components->warn('Abgebrochen – .env wurde nicht verändert.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->writeHashToEnv($hash)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info('PAYONE_KEY_HASH in die .env geschrieben.');
|
||||
$this->line(' Danach <comment>php artisan config:clear</comment> ausführen (bzw. config neu cachen).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function envPath(): string
|
||||
{
|
||||
return base_path('.env');
|
||||
}
|
||||
|
||||
private function writeHashToEnv(string $hash): bool
|
||||
{
|
||||
$path = $this->envPath();
|
||||
|
||||
if (! is_file($path) || ! is_writable($path)) {
|
||||
$this->components->error(".env nicht gefunden oder nicht beschreibbar: {$path}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$contents = (string) file_get_contents($path);
|
||||
$line = 'PAYONE_KEY_HASH='.$hash;
|
||||
|
||||
if (preg_match('/^PAYONE_KEY_HASH=.*$/m', $contents)) {
|
||||
$contents = preg_replace('/^PAYONE_KEY_HASH=.*$/m', $line, $contents);
|
||||
} else {
|
||||
$contents = rtrim($contents, "\n")."\n".$line."\n";
|
||||
}
|
||||
|
||||
return file_put_contents($path, $contents) !== false;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
namespace App\Cron;
|
||||
|
||||
use App\Http\Controllers\Pay\PayoneController;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\AboOrderCart;
|
||||
use App\Services\Payment\Payone\PayoneTransactionRecorder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Yard;
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ class UserMakeOrder
|
|||
\Log::info('Response: '.json_encode($response));
|
||||
// $response = $this->pay->ResponseData(true);
|
||||
|
||||
Log::info('Zahlungsvorgang abgeschlossen für UserAbo ID: '.$this->userAbo->id.', Status: '.($response->status ?? 'unbekannt'));
|
||||
Log::info('Zahlungsvorgang abgeschlossen für UserAbo ID: '.$this->userAbo->id.', Status: '.(is_array($response) ? ($response['status'] ?? 'unbekannt') : 'unbekannt'));
|
||||
|
||||
return $response;
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -100,19 +100,12 @@ class UserMakeOrder
|
|||
return;
|
||||
}
|
||||
|
||||
PaymentTransaction::create([
|
||||
'shopping_payment_id' => $shoppingPayment->id,
|
||||
'request' => 'authorization',
|
||||
'txid' => $responseData['txid'] ?? 0,
|
||||
'userid' => $responseData['userid'] ?? $this->userAbo->payone_userid ?? 0,
|
||||
'status' => $responseData['status'],
|
||||
'txaction' => $responseData['txaction'] ?? null,
|
||||
'transmitted_data' => $responseData,
|
||||
'errorcode' => $responseData['errorcode'] ?? null,
|
||||
'errormessage' => $responseData['errormessage'] ?? null,
|
||||
'customermessage' => $responseData['customermessage'] ?? null,
|
||||
'mode' => $shoppingPayment->mode,
|
||||
]);
|
||||
PayoneTransactionRecorder::recordFromResponse(
|
||||
$shoppingPayment,
|
||||
'authorization',
|
||||
$responseData,
|
||||
(int) ($this->userAbo->payone_userid ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
public function getShoppingPayment()
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Symfony\Component\ErrorHandler\Exception\FlattenException;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer;
|
||||
use Symfony\Component\ErrorHandler\Exception\FlattenException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
|
|
@ -17,7 +19,9 @@ class Handler extends ExceptionHandler
|
|||
* @var array
|
||||
*/
|
||||
protected $dontReport = [
|
||||
//
|
||||
// PAYONE-Transportfehler werden bereits via MyLog protokolliert (inkl. Mail);
|
||||
// kein zusätzlicher Exception-Report nötig (entspricht dem alten abort(403)-Verhalten).
|
||||
PayoneTransportException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -33,7 +37,6 @@ class Handler extends ExceptionHandler
|
|||
/**
|
||||
* Report or log an exception.
|
||||
*
|
||||
* @param \Throwable $exception
|
||||
* @return void
|
||||
*
|
||||
* @throws \Exception
|
||||
|
|
@ -51,13 +54,18 @@ class Handler extends ExceptionHandler
|
|||
* Render an exception into an HTTP response.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Throwable $exception
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function render($request, Throwable $exception)
|
||||
{
|
||||
// Fallback für nicht im Checkout abgefangene PAYONE-Transportfehler:
|
||||
// identisch zum früheren abort(403, $message) in Payone::sendRequest.
|
||||
if ($exception instanceof PayoneTransportException) {
|
||||
$exception = new HttpException(403, $exception->getMessage(), $exception);
|
||||
}
|
||||
|
||||
return parent::render($request, $exception);
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +73,6 @@ class Handler extends ExceptionHandler
|
|||
* Convert an authentication exception into a response.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Illuminate\Auth\AuthenticationException $exception
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function unauthenticated($request, \Illuminate\Auth\AuthenticationException $exception)
|
||||
|
|
@ -83,12 +90,12 @@ class Handler extends ExceptionHandler
|
|||
// 'crm' => 'login', // CRM hat eine eigene login route
|
||||
// default => 'login'
|
||||
// };
|
||||
|
||||
|
||||
// Temporär: Verwende Standard-Login-Route
|
||||
return redirect()->guest(route('login'));
|
||||
} catch (\Exception $e) {
|
||||
// Fallback: Weiterleitung zur Hauptdomain
|
||||
return redirect()->guest('https://' . config('app.domain') . config('app.tld_care') . '/login');
|
||||
return redirect()->guest('https://'.config('app.domain').config('app.tld_care').'/login');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,24 +106,23 @@ class Handler extends ExceptionHandler
|
|||
$handler = new HtmlErrorRenderer(true); // boolean, true raises debug flag...
|
||||
$css = $handler->getStylesheet();
|
||||
$content = $handler->getBody($e);
|
||||
//Mail::to(config('app.exception_mail'))->send(new MailContact($contact));
|
||||
// Mail::to(config('app.exception_mail'))->send(new MailContact($contact));
|
||||
// Verwende normale Mail-Klasse statt Facade, um Probleme bei der Initialisierung zu vermeiden
|
||||
$to = config('app.exception_mail');
|
||||
$subject = 'mivita Exception: ' . \Request::fullUrl();
|
||||
$subject = 'mivita Exception: '.\Request::fullUrl();
|
||||
|
||||
if ($to) {
|
||||
\Mail::send('emails.exception', compact('css', 'content'), function ($message) use ($to, $subject) {
|
||||
$message
|
||||
->to($to)
|
||||
->subject($subject)
|
||||
;
|
||||
->subject($subject);
|
||||
});
|
||||
}
|
||||
} catch (Throwable $ex) {
|
||||
// Einfache Fehlerprotokollierung ohne Facade
|
||||
file_put_contents(
|
||||
storage_path('logs/laravel-' . date('Y-m-d') . '.log'),
|
||||
'[' . date('Y-m-d H:i:s') . '] exception-handler-error: ' . $ex->getMessage() . "\n",
|
||||
storage_path('logs/laravel-'.date('Y-m-d').'.log'),
|
||||
'['.date('Y-m-d H:i:s').'] exception-handler-error: '.$ex->getMessage()."\n",
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserAbo;
|
||||
use App\Repositories\AboRepository;
|
||||
use App\Services\AboExternalCompletionService;
|
||||
use App\Services\AboItemHistoryService;
|
||||
use App\Services\AboOrderCart;
|
||||
use App\Services\AboRetryPaymentService;
|
||||
|
|
@ -24,24 +25,64 @@ class AboController extends Controller
|
|||
public function index()
|
||||
{
|
||||
if (Request::get('reset') === 'filter') {
|
||||
set_user_attr('filter_user_shop_id', null);
|
||||
set_user_attr('filter_status', null);
|
||||
set_user_attr('filter_member_id', null);
|
||||
foreach (['filter_member_id', 'filter_status', 'filter_active', 'filter_is_for', 'filter_payment', 'filter_start_month', 'filter_start_year', 'filter_next_day'] as $attr) {
|
||||
set_user_attr($attr, null);
|
||||
}
|
||||
|
||||
return redirect(route('admin_sales_customers'));
|
||||
return redirect(route('admin_abos'));
|
||||
}
|
||||
|
||||
// $filter_user_shops = UserAbo::join('user_shops', 'user_shop_id', '=', 'user_shops.id')->orderBy('slug')->get()->pluck('slug', 'id')->unique()->toArray();
|
||||
$filter_members = UserAbo::join('users', 'user_id', '=', 'users.id')->groupBy('user_id')->join('user_accounts', 'account_id', '=', 'user_accounts.id')->select('users.id', 'users.email', 'user_accounts.first_name', 'user_accounts.last_name')->get();
|
||||
|
||||
$data = [
|
||||
// 'filter_user_shops' => $filter_user_shops,
|
||||
'filter_members' => $filter_members,
|
||||
'filter_statuses' => $this->getStatusFilterOptions(),
|
||||
'filter_months' => $this->getMonthFilterOptions(),
|
||||
'filter_years' => $this->getStartYearFilterOptions(),
|
||||
'filter_next_days' => UserAbo::$aboDeliveryDays,
|
||||
];
|
||||
|
||||
return view('admin.abo.index', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getStatusFilterOptions(): array
|
||||
{
|
||||
return UserAbo::query()
|
||||
->select('status')
|
||||
->distinct()
|
||||
->orderBy('status')
|
||||
->pluck('status')
|
||||
->mapWithKeys(fn ($status) => [$status => __('abo.'.(UserAbo::$statusTypes[$status] ?? $status))])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getMonthFilterOptions(): array
|
||||
{
|
||||
$months = [];
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$months[$month] = \Carbon\Carbon::create(null, $month, 1)->locale(app()->getLocale())->translatedFormat('F');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function getStartYearFilterOptions(): array
|
||||
{
|
||||
$minStart = UserAbo::whereNotNull('start_date')->min('start_date');
|
||||
$fromYear = $minStart ? (int) substr($minStart, 0, 4) : (int) date('Y');
|
||||
|
||||
return range((int) date('Y'), $fromYear);
|
||||
}
|
||||
|
||||
public function detail($id)
|
||||
{
|
||||
$data = Request::all();
|
||||
|
|
@ -113,6 +154,16 @@ class AboController extends Controller
|
|||
return redirect(route('admin_abos_detail', [$id]));
|
||||
}
|
||||
|
||||
public function externDone($id, AboExternalCompletionService $externalCompletion)
|
||||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$result = $externalCompletion->completeForAbo($user_abo);
|
||||
|
||||
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
|
||||
|
||||
return redirect(route('admin_abos_detail', [$id]));
|
||||
}
|
||||
|
||||
public function datatable()
|
||||
{
|
||||
|
||||
|
|
@ -128,6 +179,36 @@ class AboController extends Controller
|
|||
$query->where('status', '=', Request::get('filter_status'));
|
||||
}
|
||||
|
||||
set_user_attr('filter_active', Request::get('filter_active'));
|
||||
if (Request::get('filter_active') != '') {
|
||||
$query->where('active', '=', (int) Request::get('filter_active'));
|
||||
}
|
||||
|
||||
set_user_attr('filter_is_for', Request::get('filter_is_for'));
|
||||
if (Request::get('filter_is_for') != '') {
|
||||
$query->where('is_for', '=', Request::get('filter_is_for'));
|
||||
}
|
||||
|
||||
set_user_attr('filter_payment', Request::get('filter_payment'));
|
||||
if (Request::get('filter_payment') != '') {
|
||||
$query->where('clearingtype', '=', Request::get('filter_payment'));
|
||||
}
|
||||
|
||||
set_user_attr('filter_start_month', Request::get('filter_start_month'));
|
||||
if (Request::get('filter_start_month') != '') {
|
||||
$query->whereMonth('start_date', (int) Request::get('filter_start_month'));
|
||||
}
|
||||
|
||||
set_user_attr('filter_start_year', Request::get('filter_start_year'));
|
||||
if (Request::get('filter_start_year') != '') {
|
||||
$query->whereYear('start_date', (int) Request::get('filter_start_year'));
|
||||
}
|
||||
|
||||
set_user_attr('filter_next_day', Request::get('filter_next_day'));
|
||||
if (Request::get('filter_next_day') != '') {
|
||||
$query->where('abo_interval', '=', (int) Request::get('filter_next_day'));
|
||||
}
|
||||
|
||||
return \DataTables::eloquent($query)
|
||||
->addColumn('id', function (UserAbo $user_abo) {
|
||||
return '<a href="'.route('admin_abos_detail', [$user_abo->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
|
||||
|
|
@ -168,6 +249,11 @@ class AboController extends Controller
|
|||
->addColumn('payone_userid', function (UserAbo $user_abo) {
|
||||
return $user_abo->payone_userid;
|
||||
})
|
||||
->filterColumn('payone_userid', function ($query, $keyword) {
|
||||
if ($keyword != '') {
|
||||
$query->whereRaw('user_abos.payone_userid LIKE ?', ['%'.$keyword.'%']);
|
||||
}
|
||||
})
|
||||
|
||||
->orderColumn('id', 'id $1')
|
||||
->orderColumn('start_date', 'start_date $1')
|
||||
|
|
|
|||
|
|
@ -3,247 +3,17 @@
|
|||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\MyLog;
|
||||
use App\Services\Payment;
|
||||
use App\Services\ShoppingUserService;
|
||||
use App\Services\Util;
|
||||
use App\Services\Payment\Payone\PayoneWebhookHandler;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class PayoneController extends Controller
|
||||
{
|
||||
public function __construct() {}
|
||||
public function __construct(private readonly PayoneWebhookHandler $webhookHandler) {}
|
||||
|
||||
public function paymentStatus()
|
||||
public function paymentStatus(): Response
|
||||
{
|
||||
$result = $this->webhookHandler->handle(\Request::all());
|
||||
|
||||
$data = \Request::all();
|
||||
// test para
|
||||
|
||||
/* $data = [
|
||||
'key' => '698fb2555f8b2efc74f60b2121421f45',
|
||||
'txaction' => 'paid',
|
||||
'clearingtype' => 'wlt',
|
||||
'userid' => '158723953',
|
||||
'txid' => '321623031',
|
||||
'price' => '89.00',
|
||||
'param' => '1', //$this->shopping_order->id,
|
||||
'reference' => '15c83aee2766c3',
|
||||
];
|
||||
|
||||
*/
|
||||
|
||||
if (! isset($data['key']) || ! isset($data['param']) || ! isset($data['userid']) || ! isset($data['txid']) || ! isset($data['reference']) || ! isset($data['price'])) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2001 App\Http\Controllers\Api\PayoneController::paymentStatus parameter incomplete',
|
||||
$data
|
||||
);
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($data['key'] != config('payone.defaults.key')) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2002 App\Http\Controllers\Api\PayoneController::paymentStatus Key error',
|
||||
$data
|
||||
);
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
}
|
||||
|
||||
$shopping_order = ShoppingOrder::find($data['param']);
|
||||
if (! $shopping_order) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2003 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingOrder not found:',
|
||||
$data
|
||||
);
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
}
|
||||
|
||||
$shopping_payment = ShoppingPayment::where('reference', $data['reference'])->first();
|
||||
if (! $shopping_payment) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2004 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingPayment not found',
|
||||
$data
|
||||
);
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($shopping_payment->shopping_order_id != $shopping_order->id) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2005 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingPayment no realation ShoppingOrder',
|
||||
$data
|
||||
);
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
}
|
||||
|
||||
$price = number_format((round($data['price'], 2) * 100), 0, '.', '');
|
||||
$price_amount = number_format($shopping_payment->amount, 0, '.', '');
|
||||
if ($price_amount != $price) {
|
||||
$data['shopping_payment-amount'] = $price_amount;
|
||||
$data['price-amount'] = $price;
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2006 App\Http\Controllers\Api\PayoneController::paymentStatus Price error',
|
||||
$data
|
||||
);
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
}
|
||||
/*
|
||||
* Payone sendet dieselbe txaction oft mehrfach (v. a. "appointed"). War der Status
|
||||
* bereits auf ShoppingPayment gespeichert, ist das ein Duplikat: TSOK, keine Doppel-Verarbeitung.
|
||||
* Ausnahme: erneutes "paid", obwohl die Bestellung noch nicht als bezahlt gefuehrt wird (Recovery).
|
||||
*/
|
||||
if ($shopping_payment->txaction == $data['txaction']) {
|
||||
if ($data['txaction'] === 'paid' && $shopping_order->txaction === 'paid') {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'notice',
|
||||
'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (already paid)',
|
||||
$data,
|
||||
false
|
||||
);
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
}
|
||||
|
||||
if (in_array($data['txaction'], ['appointed', 'failed', 'pending'], true)) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'info',
|
||||
'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (same txaction)',
|
||||
[
|
||||
'reference' => $data['reference'] ?? null,
|
||||
'param' => $data['param'] ?? null,
|
||||
'txaction' => $data['txaction'],
|
||||
'txid' => $data['txid'] ?? null,
|
||||
],
|
||||
false
|
||||
);
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// create transaction
|
||||
PaymentTransaction::create([
|
||||
'shopping_payment_id' => $shopping_payment->id,
|
||||
'request' => 'transaction',
|
||||
'txid' => $data['txid'],
|
||||
'userid' => $data['userid'],
|
||||
'status' => 'PAYONE',
|
||||
'key' => $data['key'],
|
||||
'txaction' => $data['txaction'],
|
||||
'transmitted_data' => Util::utf8ize($data),
|
||||
'mode' => $data['mode'],
|
||||
'errorcode' => $data['errorcode'] ?? null,
|
||||
'errormessage' => $data['errormessage'] ?? null,
|
||||
'customermessage' => $data['customermessage'] ?? null,
|
||||
]);
|
||||
|
||||
// Define txaction priority (higher number = higher priority)
|
||||
$txaction_priority = [
|
||||
'appointed' => 1,
|
||||
'pending' => 2,
|
||||
'failed' => 3,
|
||||
'paid' => 10, // highest priority - final state
|
||||
];
|
||||
|
||||
$current_priority = isset($txaction_priority[$shopping_order->txaction]) ? $txaction_priority[$shopping_order->txaction] : 0;
|
||||
$new_priority = isset($txaction_priority[$data['txaction']]) ? $txaction_priority[$data['txaction']] : 0;
|
||||
|
||||
// Only update txaction if new priority is higher than current
|
||||
if ($new_priority > $current_priority) {
|
||||
$shopping_order->txaction = $data['txaction'];
|
||||
$shopping_order->save();
|
||||
$shopping_payment->txaction = $data['txaction'];
|
||||
$shopping_payment->save();
|
||||
} else {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'info',
|
||||
'App\Http\Controllers\Api\PayoneController::paymentStatus - txaction not updated (current: '.$shopping_order->txaction.' has higher/equal priority than new: '.$data['txaction'].')',
|
||||
$data,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
$send_link = false;
|
||||
$send_mail = true;
|
||||
if ($data['txaction'] === 'failed') {
|
||||
$shopping_order->setUserHistoryValue(['status' => 6]);
|
||||
Util::setInstanceStatusByPayment($shopping_payment, 5);
|
||||
}
|
||||
if ($data['txaction'] === 'appointed') {
|
||||
$shopping_order->setUserHistoryValue(['status' => 7]);
|
||||
ShoppingUserService::snycOrdersByShoppingOrder($shopping_order);
|
||||
Util::setInstanceStatusByPayment($shopping_payment, 4);
|
||||
}
|
||||
|
||||
if ($data['txaction'] === 'paid') {
|
||||
// Use DB transaction and row locking to prevent race conditions
|
||||
\DB::beginTransaction();
|
||||
try {
|
||||
// Lock the shopping order row to prevent concurrent processing
|
||||
$locked_order = ShoppingOrder::where('id', $shopping_order->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
// Double-check if payment was already processed
|
||||
if (! $locked_order->paid) {
|
||||
$send_link = Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment);
|
||||
\DB::commit();
|
||||
} else {
|
||||
$send_mail = false;
|
||||
\DB::commit();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\DB::rollBack();
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2008 App\Http\Controllers\Api\PayoneController::paymentStatus Transaction failed',
|
||||
['error' => $e->getMessage(), 'data' => $data]
|
||||
);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $shopping_payment->id,
|
||||
txaction: 'paid',
|
||||
metadata: ['mode' => $data['mode'] ?? null, 'txid' => $data['txid'] ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
if ($data['txaction'] === 'appointed') {
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $shopping_payment->id,
|
||||
txaction: 'appointed',
|
||||
metadata: ['mode' => $data['mode'] ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
$data['send_link'] = $send_link;
|
||||
if ($send_mail) {
|
||||
Payment::paymentStatusSendMail($shopping_order, $shopping_payment, $data);
|
||||
}
|
||||
echo 'TSOK';
|
||||
exit;
|
||||
return response($result->response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,31 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* clearingtype
|
||||
elv Debit payment
|
||||
cc Credit card
|
||||
rec Invoice
|
||||
cod Cash on delivery
|
||||
vor Prepayment
|
||||
sb Online Bank Transfer
|
||||
wlt e-wallet
|
||||
fnc Financing
|
||||
*/
|
||||
/*private $payment_methods= [
|
||||
'wlt#PPE' => 'PayPal',
|
||||
'cc' => 'CreditCard',
|
||||
'sb#GPY' => 'giropay',
|
||||
'sb#PNT' => 'Sofort',
|
||||
'wlt#PDT' => 'paydirekt',
|
||||
'fnc' => 'Rechnungskauf',
|
||||
'pref' => 'Vorauskasse',
|
||||
];*/
|
||||
|
||||
namespace App\Http\Controllers\Pay;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\MyLog;
|
||||
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
|
||||
use App\Services\Payment\Payone\PaymentMethodResolver;
|
||||
use App\Services\Payment\Payone\PayonePaymentService;
|
||||
use App\Services\Payment\Payone\PayoneRequestBuilder;
|
||||
use App\Services\Payment\Payone\PayoneTransactionRecorder;
|
||||
use App\Services\Payone;
|
||||
use Util;
|
||||
|
||||
/**
|
||||
* @deprecated Legacy stateful PAYONE payment facade. New code should depend on
|
||||
* {@see PayonePaymentService} (Checkout-/Abo-/CC-Check) plus the extracted helpers
|
||||
* {@see PayoneRequestBuilder}, {@see PaymentMethodResolver} and
|
||||
* {@see PayoneTransactionRecorder} directly. This controller stays only as a thin
|
||||
* facade for existing callers (CheckoutController, UserMakeOrder, PaymentHelper)
|
||||
* and is migrated away in Phase 1.
|
||||
*
|
||||
* clearingtype reference (resolved in {@see PaymentMethodResolver}):
|
||||
* cc = credit card, elv = SEPA debit, vor = prepayment, rec = invoice,
|
||||
* sb = online bank transfer, wlt = e-wallet, fnc = financing.
|
||||
*/
|
||||
class PayoneController extends Controller
|
||||
{
|
||||
const PREAUTHORIZATION = 'preauthorization';
|
||||
|
|
@ -53,11 +48,8 @@ class PayoneController extends Controller
|
|||
|
||||
private $prepayment = [];
|
||||
|
||||
/* private $onlineTransfer = [];
|
||||
private $creditCard = []; */
|
||||
private $deliveryData = [];
|
||||
|
||||
// private $payment_method;
|
||||
private $urls = [];
|
||||
|
||||
private $shopping_user;
|
||||
|
|
@ -68,10 +60,14 @@ class PayoneController extends Controller
|
|||
|
||||
private $reference;
|
||||
|
||||
private PayonePaymentService $paymentService;
|
||||
|
||||
private ?PayonePaymentResult $lastPaymentResult = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
$this->default = \Config::get('payone.defaults');
|
||||
$this->paymentService = app(PayonePaymentService::class);
|
||||
}
|
||||
|
||||
public function init($shopping_user, $shopping_order)
|
||||
|
|
@ -86,47 +82,16 @@ class PayoneController extends Controller
|
|||
return $this->shopping_payment;
|
||||
}
|
||||
|
||||
public function setAboPayment($user_abo, $amount, $currency)
|
||||
public function setAboPayment($user_abo, $amount, $currency): void
|
||||
{
|
||||
$this->reference = substr(uniqid('m', false), 0, 16);
|
||||
$amount = intval($amount);
|
||||
$this->method = [
|
||||
'clearingtype' => $user_abo->clearingtype,
|
||||
'wallettype' => $user_abo->wallettype,
|
||||
'pseudocardpan' => isset($user_abo->carddata['pseudocardpan']) ? $user_abo->carddata['pseudocardpan'] : '',
|
||||
'cardexpiredate' => isset($user_abo->carddata['cardexpiredate']) ? $user_abo->carddata['cardexpiredate'] : '',
|
||||
'userid' => $user_abo->payone_userid,
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
];
|
||||
|
||||
$this->aboInitPayment = [
|
||||
'recurrence' => 'recurring',
|
||||
'customer_is_present' => 'no',
|
||||
'request' => 'authorization',
|
||||
'amount' => $amount,
|
||||
];
|
||||
|
||||
$this->prepayment = [
|
||||
'reference' => $this->reference, // a unique reference, e.g. order number
|
||||
'amount' => $amount, // amount in smallest currency unit, i.e. cents
|
||||
'currency' => $currency,
|
||||
'param' => $this->shopping_order->id,
|
||||
];
|
||||
|
||||
$this->shopping_payment = ShoppingPayment::create([
|
||||
'shopping_order_id' => $this->shopping_order->id,
|
||||
'clearingtype' => $this->method['clearingtype'],
|
||||
'wallettype' => $this->method['wallettype'],
|
||||
'onlinebanktransfertype' => $this->method['onlinebanktransfertype'],
|
||||
'carddata' => $user_abo->carddata,
|
||||
'reference' => $this->reference,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'is_abo' => $this->shopping_order->is_abo,
|
||||
'abo_interval' => 0,
|
||||
'mode' => $this->shopping_order->mode,
|
||||
]);
|
||||
$this->lastPaymentResult = $this->paymentService->chargeAbo(
|
||||
$user_abo,
|
||||
$this->shopping_order,
|
||||
$this->shopping_user,
|
||||
intval($amount),
|
||||
$currency,
|
||||
);
|
||||
$this->shopping_payment = $this->lastPaymentResult->shoppingPayment;
|
||||
}
|
||||
|
||||
// make Payone payment
|
||||
|
|
@ -206,150 +171,48 @@ class PayoneController extends Controller
|
|||
|
||||
public function setPersonalData()
|
||||
{
|
||||
$this->personalData = [
|
||||
'firstname' => $this->shopping_user->billing_firstname,
|
||||
'lastname' => $this->shopping_user->billing_lastname, // mandatory
|
||||
'street' => $this->shopping_user->billing_address,
|
||||
'zip' => $this->shopping_user->billing_zipcode,
|
||||
'city' => $this->shopping_user->billing_city,
|
||||
'country' => ($this->shopping_user->billing_country) ? $this->shopping_user->billing_country->code : 'DE', // mandatory
|
||||
'email' => $this->shopping_user->billing_email,
|
||||
// "language" => ($this->shopping_user->billing_country) ? strtoupper($this->shopping_user->billing_country->code) : "DE", // mandatory
|
||||
'language' => 'DE',
|
||||
];
|
||||
|
||||
/**
|
||||
* Paydirekt requires both, personal data and shipping data
|
||||
*/
|
||||
/* $this->deliveryData = array(
|
||||
"shipping_firstname" => "Paul",
|
||||
"shipping_lastname" => "Neverpayer",
|
||||
"shipping_street" => "Hamburger Allee 26-28",
|
||||
"shipping_zip" => "60486",
|
||||
"shipping_city" => "Frankfurt am Main",
|
||||
"shipping_country" => "DE"
|
||||
);*/
|
||||
$this->personalData = PayoneRequestBuilder::buildPersonalData($this->shopping_user);
|
||||
}
|
||||
|
||||
private function setMethod($payment_method, $ret = [])
|
||||
{
|
||||
|
||||
if ($payment_method) {
|
||||
if (strpos($payment_method, '#')) {
|
||||
$payment_method = explode('#', $payment_method);
|
||||
// wallet Paypal
|
||||
if ($payment_method[0] === 'wlt') {
|
||||
$this->method = [
|
||||
'clearingtype' => 'wlt',
|
||||
'wallettype' => $payment_method[1],
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
];
|
||||
}
|
||||
// Online-Überweisung
|
||||
if ($payment_method[0] === 'sb') {
|
||||
$this->method = [
|
||||
'clearingtype' => 'sb',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => $payment_method[1], // this is the type for Sofort.com
|
||||
'bankcountry' => 'DE', // we need to know the country of the customer's bank, i.e. of the invoice address
|
||||
'request' => 'authorization',
|
||||
];
|
||||
}
|
||||
|
||||
// Rechnungskauf
|
||||
if ($payment_method[0] === 'fnc') {
|
||||
// MIVITA
|
||||
if (isset($payment_method[1]) && $payment_method[1] === 'MIV') {
|
||||
$this->method = [
|
||||
'clearingtype' => 'fnc',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => 'MIV',
|
||||
'request' => 'authorization',
|
||||
];
|
||||
}
|
||||
// PAYONE
|
||||
/* $this->method = [
|
||||
"clearingtype" => "fnc",
|
||||
"wallettype" => "",
|
||||
'onlinebanktransfertype' => "",
|
||||
"financingtype" => "PYV",
|
||||
"request" => "genericpayment",
|
||||
"add_paydata[action]" => "pre_check",
|
||||
"add_paydata[payment_type]" => "Payolution-Invoicing",
|
||||
];*/
|
||||
}
|
||||
}
|
||||
// vorkasse
|
||||
if ($payment_method === 'elv') {
|
||||
$this->method = [
|
||||
'clearingtype' => 'elv',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
'mandate_identification' => $ret['elv']['mandate_identification'],
|
||||
'iban' => $ret['elv']['iban'],
|
||||
'bic' => $ret['elv']['bic'],
|
||||
'bankaccountholder' => $ret['elv']['bankaccountholder'],
|
||||
// "bankcountry" => "DE",
|
||||
];
|
||||
}
|
||||
|
||||
// vorkasse
|
||||
if ($payment_method === 'vor') {
|
||||
$this->method = [
|
||||
'clearingtype' => 'vor',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
];
|
||||
}
|
||||
|
||||
// CreditCard
|
||||
if ($payment_method === 'cc') {
|
||||
// need the $cc_ret
|
||||
$this->method = [
|
||||
'clearingtype' => 'cc',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
'pseudocardpan' => $ret['cc']['pseudocardpan'],
|
||||
// "xid" => "3-D Secure transaction ID"
|
||||
];
|
||||
}
|
||||
$this->method = PaymentMethodResolver::resolve($payment_method, $ret)->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
public function onlyPaymentResponse()
|
||||
public function onlyPaymentResponse(): array
|
||||
{
|
||||
$request = array_merge($this->default, $this->personalData, $this->deliveryData, $this->method, $this->prepayment, $this->aboInitPayment, $this->urls);
|
||||
$response = Payone::sendRequest($request);
|
||||
// Abo path: chargeAbo (called from setAboPayment) already fetched the response.
|
||||
if ($this->lastPaymentResult !== null) {
|
||||
return $this->lastPaymentResult->toArray();
|
||||
}
|
||||
|
||||
return $response;
|
||||
// Checkout path: state was set by setPrePayment + setPersonalData + setDeliverylData.
|
||||
$request = PayoneRequestBuilder::buildRequest(
|
||||
$this->default, $this->personalData, $this->deliveryData,
|
||||
$this->method, $this->prepayment, $this->aboInitPayment, $this->urls,
|
||||
);
|
||||
|
||||
return Payone::sendRequest($request);
|
||||
}
|
||||
|
||||
public function ResponseData($is_abo = false)
|
||||
{
|
||||
|
||||
$request = array_merge($this->default, $this->personalData, $this->deliveryData, $this->method, $this->prepayment, $this->aboInitPayment, $this->urls);
|
||||
$request = PayoneRequestBuilder::buildRequest($this->default, $this->personalData, $this->deliveryData, $this->method, $this->prepayment, $this->aboInitPayment, $this->urls);
|
||||
if ($this->shopping_payment->clearingtype === 'fnc' && $this->shopping_payment->onlinebanktransfertype === 'MIV') {
|
||||
$payt = PaymentTransaction::create([
|
||||
'shopping_payment_id' => $this->shopping_payment->id,
|
||||
'request' => $this->method['request'],
|
||||
'txid' => 0,
|
||||
'userid' => 0,
|
||||
'status' => 'FNCMIV',
|
||||
'transmitted_data' => $request,
|
||||
'txaction' => 'invoice_open',
|
||||
'mode' => $this->shopping_payment->mode,
|
||||
]);
|
||||
$payt = PayoneTransactionRecorder::recordInvoicePayment(
|
||||
$this->shopping_payment,
|
||||
$this->method['request'],
|
||||
$request,
|
||||
);
|
||||
Util::setUserHistoryValue(['status' => 5]);
|
||||
if ($is_abo) {
|
||||
return $this->reference;
|
||||
}
|
||||
|
||||
return redirect(route('checkout.transaction_approved', [$payt->id, $this->reference]));
|
||||
exit;
|
||||
}
|
||||
$response = Payone::sendRequest($request);
|
||||
/*
|
||||
|
|
@ -362,16 +225,11 @@ class PayoneController extends Controller
|
|||
'PayPal Preauthorization Fehler: '.$response['errormessage'],
|
||||
$response
|
||||
);
|
||||
PaymentTransaction::create([
|
||||
'shopping_payment_id' => $this->shopping_payment->id,
|
||||
'request' => $this->method['request'],
|
||||
'errorcode' => $response['errorcode'],
|
||||
'errormessage' => $response['errormessage'],
|
||||
'customermessage' => $response['customermessage'],
|
||||
'transmitted_data' => $request,
|
||||
'status' => $response['status'],
|
||||
'mode' => $this->shopping_payment->mode,
|
||||
]);
|
||||
PayoneTransactionRecorder::recordFromResponse(
|
||||
$this->shopping_payment,
|
||||
$this->method['request'],
|
||||
$response,
|
||||
);
|
||||
Util::setUserHistoryValue(['status' => 3]);
|
||||
if ($is_abo) {
|
||||
return $response;
|
||||
|
|
@ -383,36 +241,25 @@ class PayoneController extends Controller
|
|||
}
|
||||
|
||||
if ($response['status'] === 'REDIRECT') {
|
||||
PaymentTransaction::create([
|
||||
'shopping_payment_id' => $this->shopping_payment->id,
|
||||
'request' => $this->method['request'],
|
||||
'txid' => $response['txid'],
|
||||
'userid' => $response['userid'],
|
||||
'status' => $response['status'],
|
||||
'mode' => $this->shopping_payment->mode,
|
||||
|
||||
]);
|
||||
PayoneTransactionRecorder::recordFromResponse(
|
||||
$this->shopping_payment,
|
||||
$this->method['request'],
|
||||
$response,
|
||||
);
|
||||
Util::setUserHistoryValue(['status' => 4]);
|
||||
if ($is_abo) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return redirect()->away($response['redirecturl']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($response['status'] === 'APPROVED') {
|
||||
// header("Location: " . $response["redirecturl"]); // or other redirect method
|
||||
$payt = PaymentTransaction::create([
|
||||
'shopping_payment_id' => $this->shopping_payment->id,
|
||||
'request' => $this->method['request'],
|
||||
'txid' => $response['txid'],
|
||||
'userid' => $response['userid'],
|
||||
'status' => $response['status'],
|
||||
'transmitted_data' => $response,
|
||||
'mode' => $this->shopping_payment->mode,
|
||||
|
||||
]);
|
||||
$payt = PayoneTransactionRecorder::recordFromResponse(
|
||||
$this->shopping_payment,
|
||||
$this->method['request'],
|
||||
$response,
|
||||
);
|
||||
Util::setUserHistoryValue(['status' => 5]);
|
||||
if ($is_abo) {
|
||||
return $response;
|
||||
|
|
@ -421,27 +268,25 @@ class PayoneController extends Controller
|
|||
if ($payt->shopping_payment->clearingtype === 'vor') {
|
||||
// vorkasse
|
||||
return redirect(route('checkout.transaction_approved', [$payt->id, $this->reference]));
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($payt->shopping_payment->clearingtype === 'cc') {
|
||||
// creditcard
|
||||
return redirect(route('checkout.transaction_approved', [$payt->id, $this->reference]));
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($payt->shopping_payment->clearingtype === 'elv') {
|
||||
// sepa
|
||||
return redirect(route('checkout.transaction_approved', [$payt->id, $this->reference]));
|
||||
exit;
|
||||
}
|
||||
|
||||
var_dump($response);
|
||||
exit();
|
||||
// txid
|
||||
// Payment process ID (PAYONE)
|
||||
// userid
|
||||
// Debtor ID (PAYONE)
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:1002 Unbekannter clearingtype bei APPROVED-Status. App\Http\Controllers\Pay\PayoneController::ResponseData',
|
||||
['clearingtype' => $payt->shopping_payment->clearingtype ?? null, 'txid' => $response['txid'] ?? null]
|
||||
);
|
||||
abort(403, 'Zahlung konnte nicht abgeschlossen werden. Unbekannte Zahlungsart. Fehlercode: 1002');
|
||||
}
|
||||
|
||||
if ($response['status'] === 'PENDING') {
|
||||
|
|
@ -451,11 +296,7 @@ class PayoneController extends Controller
|
|||
'Error:1000 Status PENDING App\Http\Controllers\Pay\PayoneController::ResponseData response status PENDING',
|
||||
$response
|
||||
);
|
||||
exit();
|
||||
// txid
|
||||
// Payment process ID (PAYONE)
|
||||
// userid
|
||||
// Debtor ID (PAYONE)
|
||||
abort(503, 'Ihre Zahlung wird noch verarbeitet. Bitte haben Sie einen Moment Geduld. Fehlercode: 1000');
|
||||
}
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
|
|
@ -466,148 +307,22 @@ class PayoneController extends Controller
|
|||
abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1001');
|
||||
}
|
||||
|
||||
public function checkCreditCard($data)
|
||||
public function checkCreditCard($data): array
|
||||
{
|
||||
$this->prepayment = [
|
||||
'request' => 'creditcardcheck', // create account receivable and instantly book the amount
|
||||
'cardholder' => $data['cc_cardholder_first'].' '.$data['cc_cardholder_last'],
|
||||
'cardpan' => $data['cc_cardpan'],
|
||||
'cardexpiredate' => substr($data['cc_cardexpireyear'], -2).$data['cc_cardexpiremonth'],
|
||||
'cardtype' => $data['cc_cardtype'],
|
||||
'cardcvc2' => $data['cc_cardcvc2'],
|
||||
'storecarddata' => 'yes',
|
||||
'language' => 'de',
|
||||
];
|
||||
$request = array_merge($this->default, $this->prepayment);
|
||||
|
||||
return Payone::sendRequest($request);
|
||||
return $this->paymentService
|
||||
->checkCreditCard($data, $this->default['mode'] ?? 'test')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function checkBankAccount($data, $amount, $currency, $shopping_user)
|
||||
public function checkBankAccount($data, $amount, $currency, $shopping_user): array
|
||||
{
|
||||
$this->shopping_user = $shopping_user;
|
||||
$this->setPersonalData();
|
||||
|
||||
$this->prepayment = [
|
||||
'clearingtype' => 'elv',
|
||||
'amount' => $amount, // amount in smallest currency unit, i.e. cents
|
||||
'currency' => $currency,
|
||||
'request' => 'managemandate', // create account receivable and instantly book the amount
|
||||
'bankaccountholder' => $data['elv_bankaccountholder'],
|
||||
'iban' => $data['elv_iban'],
|
||||
'bic' => $data['elv_bic'],
|
||||
|
||||
// "language" => 'de',
|
||||
];
|
||||
|
||||
$request = array_merge($this->default, $this->personalData, $this->deliveryData, $this->method, $this->prepayment, $this->urls);
|
||||
|
||||
return Payone::sendRequest($request);
|
||||
return $this->paymentService
|
||||
->checkBankAccount($data, (int) $amount, $currency, $shopping_user, $this->default['mode'] ?? 'test')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function setDeliverylData($shopping_user)
|
||||
{
|
||||
if ($shopping_user->same_as_billing == true) {
|
||||
$this->deliveryData = [
|
||||
'shipping_firstname' => $shopping_user->billing_firstname,
|
||||
'shipping_lastname' => $shopping_user->billing_lastname,
|
||||
'shipping_zip' => $shopping_user->billing_zipcode,
|
||||
'shipping_city' => $shopping_user->billing_city,
|
||||
'shipping_country' => $shopping_user->billing_country->code,
|
||||
'shipping_street' => $shopping_user->billing_address,
|
||||
];
|
||||
} else {
|
||||
$this->deliveryData = [
|
||||
'shipping_firstname' => $shopping_user->shipping_firstname,
|
||||
'shipping_lastname' => $shopping_user->shipping_lastname,
|
||||
'shipping_zip' => $shopping_user->shipping_zipcode,
|
||||
'shipping_city' => $shopping_user->shipping_city,
|
||||
'shipping_country' => $shopping_user->shipping_country->code,
|
||||
'shipping_street' => $shopping_user->shipping_address,
|
||||
];
|
||||
}
|
||||
$this->deliveryData = PayoneRequestBuilder::buildDeliveryData($shopping_user);
|
||||
}
|
||||
|
||||
/* public function getPDFFile($mandateId)
|
||||
{
|
||||
|
||||
$params['file_reference'] = $mandateId;//"XX-T0000000";
|
||||
$params['file_type'] = 'SEPA_MANDATE';
|
||||
$params['file_format'] = 'PDF';
|
||||
$request = array_merge($this->default, $params);
|
||||
|
||||
return Payone::sendRequest($request);
|
||||
}
|
||||
*/
|
||||
|
||||
/* public function checkStatus(){
|
||||
// again, the default values will be needed
|
||||
$capture = array(
|
||||
"request" => "capture",
|
||||
"txid" => "your_txid",
|
||||
"sequencenumber" => "previous_sequencenumber_plus_one", // get this from the last received transactionsstatus
|
||||
"amount" => "your_amount", // you can either capture the full amount of the tx, or less
|
||||
"currency" => "EUR"
|
||||
);
|
||||
$request = array_merge($this->default, $capture);
|
||||
$response = Payone::sendRequest($request);
|
||||
|
||||
} */
|
||||
|
||||
// set for clearingtype
|
||||
// debit payment
|
||||
/*
|
||||
*
|
||||
*
|
||||
* $params['bankaccountholder'] = $paymentData['mopt_payone__debit_bankaccountholder'];
|
||||
$params['iban'] = $this->removeWhitespaces($paymentData['mopt_payone__debit_iban']);
|
||||
$params['bic'] = $this->removeWhitespaces($paymentData['mopt_payone__debit_bic']);
|
||||
|
||||
* $params['bankcountry'] = $paymentData['mopt_payone__debit_bankcountry'];
|
||||
$params['bankaccount'] = $this->removeWhitespaces($paymentData['mopt_payone__debit_bankaccount']);
|
||||
$params['bankcode'] = $this->removeWhitespaces($paymentData['mopt_payone__debit_bankcode']);
|
||||
|
||||
|
||||
if (Shopware()->Session()->moptMandateData) {
|
||||
$params['mandate_identification'] = Shopware()->Session()->moptMandateData['mopt_payone__mandateIdentification'];
|
||||
}
|
||||
*
|
||||
* ["clearing_bankaccount"]=> string(10) "2599100003"
|
||||
["clearing_bankcode"]=> string(8) "12345678"
|
||||
["clearing_bankcountry"]=> string(2) "DE"
|
||||
["clearing_bankname"]=> string(8) "Testbank"
|
||||
["clearing_bankaccountholder"]=> string(11) "Test Nutzer"
|
||||
["clearing_bankcity"]=> string(4) "Kiel"
|
||||
["clearing_bankiban"]=> string(22) "DE00123456782599100003"
|
||||
["clearing_bankbic"]=> string(8) "TESTTEST" }
|
||||
*/
|
||||
/*
|
||||
* PNT Sofortbanking (DE, AT, CH, NL)
|
||||
GPY giropay (DE)
|
||||
EPS eps – online transfer (AT)
|
||||
PFF PostFinance E-Finance (CH)
|
||||
PFC PostFinance Card (CH)
|
||||
IDL iDEAL (NL)
|
||||
P24 Przelewy24 (PL)
|
||||
BCT Bancontact*/
|
||||
/*
|
||||
* iban
|
||||
* bic
|
||||
* bankcountry*/
|
||||
|
||||
/* * Card type
|
||||
V Visa
|
||||
M MasterCard
|
||||
A American Express
|
||||
D Diners / Discover
|
||||
J JCB
|
||||
O Maestro International
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\ShoppingPayment;
|
|||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserShop;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use App\Services\AboExternalCompletionService;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\Payment;
|
||||
|
|
@ -186,10 +187,17 @@ class SalesController extends Controller
|
|||
return view('admin.sales.customer_detail', $data);
|
||||
}
|
||||
|
||||
public function customersStore($id)
|
||||
public function customersStore($id, AboExternalCompletionService $aboExternalCompletion)
|
||||
{
|
||||
$data = Request::all();
|
||||
$change_member_error = false;
|
||||
if (($data['action'] ?? null) === 'shopping-order-abo-extern-done') {
|
||||
$shopping_order = ShoppingOrder::findOrFail($data['id']);
|
||||
$result = $aboExternalCompletion->complete($shopping_order);
|
||||
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
|
||||
|
||||
return redirect(route('admin_sales_customers_detail', [$shopping_order->id]));
|
||||
}
|
||||
if ($data['action'] === 'shopping-order-change-member') {
|
||||
if (! isset($data['change_member_key']) || $data['change_member_key'] !== config('mivita.edit_data_pass')) {
|
||||
$change_member_error = 'Das Passwort ist falsch.';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use App\Services\AboHelper;
|
|||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\Payment;
|
||||
use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
|
||||
use App\Services\Shop;
|
||||
use App\Services\Util;
|
||||
use App\User;
|
||||
|
|
@ -258,31 +259,39 @@ class CheckoutController extends Controller
|
|||
$result = [];
|
||||
$payment_method = Request::get('payment_method');
|
||||
|
||||
// Kreditkarte prüfen
|
||||
if ($payment_method === 'cc') {
|
||||
$result = $this->checkCreditCard($data, $shopping_user, $shopping_order);
|
||||
if (! is_array($result) || ! isset($result['returnstatus']) || $result['returnstatus'] !== 'VALID') {
|
||||
return $result;
|
||||
try {
|
||||
// Kreditkarte prüfen
|
||||
if ($payment_method === 'cc') {
|
||||
$result = $this->checkCreditCard($data, $shopping_user, $shopping_order);
|
||||
if (! is_array($result) || ! isset($result['returnstatus']) || $result['returnstatus'] !== 'VALID') {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SEPA prüfen
|
||||
if ($payment_method === 'elv') {
|
||||
$result = $this->checkSepaAccount($data, $shopping_user, $shopping_order);
|
||||
if (! is_array($result) || ! isset($result['returnstatus']) || $result['returnstatus'] !== 'VALID') {
|
||||
return $result;
|
||||
// SEPA prüfen
|
||||
if ($payment_method === 'elv') {
|
||||
$result = $this->checkSepaAccount($data, $shopping_user, $shopping_order);
|
||||
if (! is_array($result) || ! isset($result['returnstatus']) || $result['returnstatus'] !== 'VALID') {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Zahlung vorbereiten
|
||||
$pay = new PayoneController;
|
||||
$pay->init($shopping_user, $shopping_order);
|
||||
$amount = Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100;
|
||||
$reference = $pay->setPrePayment($payment_method, $amount, 'EUR', $result);
|
||||
$this->checkoutRepo->putSessionPayments('payment_reference', $reference);
|
||||
$pay->setPersonalData();
|
||||
|
||||
return $pay->ResponseData();
|
||||
} catch (PayoneTransportException $e) {
|
||||
// Zahlungsanbieter nicht erreichbar: zurück zur Zahlungsseite mit Fehlermeldung
|
||||
// (statt 403-Fehlerseite wie früher beim abort()).
|
||||
Session::flash('errormessage', $e->getMessage());
|
||||
|
||||
return redirect(route('checkout.checkout_card'))->withInput(Request::all());
|
||||
}
|
||||
|
||||
// Zahlung vorbereiten
|
||||
$pay = new PayoneController;
|
||||
$pay->init($shopping_user, $shopping_order);
|
||||
$amount = Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100;
|
||||
$reference = $pay->setPrePayment($payment_method, $amount, 'EUR', $result);
|
||||
$this->checkoutRepo->putSessionPayments('payment_reference', $reference);
|
||||
$pay->setPersonalData();
|
||||
|
||||
return $pay->ResponseData();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ class UserAbo extends Model
|
|||
5 => 'abo_finish',
|
||||
6 => 'abo_inactive',
|
||||
7 => 'abo_grace',
|
||||
8 => 'abo_extern_done',
|
||||
];
|
||||
|
||||
public static $statusColors = [
|
||||
|
|
@ -147,6 +148,7 @@ class UserAbo extends Model
|
|||
5 => 'info',
|
||||
6 => 'warning',
|
||||
7 => 'danger',
|
||||
8 => 'success',
|
||||
];
|
||||
|
||||
public function user()
|
||||
|
|
@ -218,8 +220,14 @@ class UserAbo extends Model
|
|||
|
||||
public function getCountPaidOrders()
|
||||
{
|
||||
// sind bezahlte Bestellungen
|
||||
return $this->user_abo_orders->where('status', '>=', 2)->where('paid', true)->count();
|
||||
// Ausgefuehrte Abo-Lieferungen: bezahlte sowie extern erledigte (Status 8,
|
||||
// abo_extern_done) Bestellungen. Extern erledigt = Lieferung ausgefuehrt und
|
||||
// Punkte vergeben, daher zaehlt sie wie eine bezahlte Lieferung (z. B. fuer die
|
||||
// Mindestlaufzeit), obwohl paid=false bleibt (Inkasso laeuft ausserhalb).
|
||||
return $this->user_abo_orders
|
||||
->where('status', '>=', 2)
|
||||
->filter(fn (UserAboOrder $order) => $order->paid || (int) $order->status === 8)
|
||||
->count();
|
||||
}
|
||||
|
||||
public function setStartDateAttribute($value)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property Carbon|null $updated_at
|
||||
* @property ShoppingOrder $shopping_order
|
||||
* @property UserAbo $user_abo
|
||||
* @package App\Models
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder query()
|
||||
|
|
@ -31,73 +30,74 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereStatus($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereUserAboId($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class UserAboOrder extends Model
|
||||
{
|
||||
protected $table = 'user_abo_orders';
|
||||
protected $table = 'user_abo_orders';
|
||||
|
||||
protected $casts = [
|
||||
'user_abo_id' => 'int',
|
||||
'shopping_order_id' => 'int',
|
||||
'status' => 'int',
|
||||
'paid' => 'bool'
|
||||
];
|
||||
protected $casts = [
|
||||
'user_abo_id' => 'int',
|
||||
'shopping_order_id' => 'int',
|
||||
'status' => 'int',
|
||||
'paid' => 'bool',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'user_abo_id',
|
||||
'user_id',
|
||||
'shopping_order_id',
|
||||
'status',
|
||||
'paid'
|
||||
];
|
||||
protected $fillable = [
|
||||
'user_abo_id',
|
||||
'user_id',
|
||||
'shopping_order_id',
|
||||
'status',
|
||||
'paid',
|
||||
];
|
||||
|
||||
public static $statusTypes = [
|
||||
0 => 'abo_new',
|
||||
1 => 'abo_new',
|
||||
2 => 'abo_okay',
|
||||
3 => 'abo_hold',
|
||||
4 => 'abo_cancel',
|
||||
5 => 'abo_finish',
|
||||
6 => 'abo_inactive',
|
||||
7 => 'abo_grace'
|
||||
];
|
||||
public static $statusTypes = [
|
||||
0 => 'abo_new',
|
||||
1 => 'abo_new',
|
||||
2 => 'abo_okay',
|
||||
3 => 'abo_hold',
|
||||
4 => 'abo_cancel',
|
||||
5 => 'abo_finish',
|
||||
6 => 'abo_inactive',
|
||||
7 => 'abo_grace',
|
||||
8 => 'abo_extern_done',
|
||||
];
|
||||
|
||||
public static $statusColors = [
|
||||
0 => 'success',
|
||||
1 => 'success',
|
||||
2 => 'secondary',
|
||||
3 => 'warning',
|
||||
4 => 'danger',
|
||||
5 => 'info',
|
||||
6 => 'warning',
|
||||
7 => 'danger'
|
||||
];
|
||||
public static $statusColors = [
|
||||
0 => 'success',
|
||||
1 => 'success',
|
||||
2 => 'secondary',
|
||||
3 => 'warning',
|
||||
4 => 'danger',
|
||||
5 => 'info',
|
||||
6 => 'warning',
|
||||
7 => 'danger',
|
||||
8 => 'success',
|
||||
];
|
||||
|
||||
public function shopping_order()
|
||||
{
|
||||
return $this->belongsTo(ShoppingOrder::class);
|
||||
}
|
||||
|
||||
public function user_abo()
|
||||
{
|
||||
return $this->belongsTo(UserAbo::class);
|
||||
}
|
||||
|
||||
public function shopping_order()
|
||||
{
|
||||
return $this->belongsTo(ShoppingOrder::class);
|
||||
}
|
||||
public function getStatusFormated()
|
||||
{
|
||||
return '<span class="badge badge-pill badge-'.$this->getStatusColor().'">'.$this->getStatusType().'</span>';
|
||||
}
|
||||
|
||||
public function user_abo()
|
||||
{
|
||||
return $this->belongsTo(UserAbo::class);
|
||||
}
|
||||
public function getStatusType()
|
||||
{
|
||||
return isset(self::$statusTypes[$this->status]) ? __('abo.'.self::$statusTypes[$this->status]) : '';
|
||||
}
|
||||
|
||||
public function getStatusFormated()
|
||||
{
|
||||
return '<span class="badge badge-pill badge-' . $this->getStatusColor() . '">' . $this->getStatusType() . '</span>';
|
||||
}
|
||||
|
||||
public function getStatusType()
|
||||
{
|
||||
return isset(self::$statusTypes[$this->status]) ? __('abo.' . self::$statusTypes[$this->status]) : "";
|
||||
}
|
||||
|
||||
public function getStatusColor()
|
||||
{
|
||||
return isset(self::$statusColors[$this->status]) ? self::$statusColors[$this->status] : "default";
|
||||
}
|
||||
public function getStatusColor()
|
||||
{
|
||||
return isset(self::$statusColors[$this->status]) ? self::$statusColors[$this->status] : 'default';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Payment\Payone\PayonePaymentService;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
|
@ -53,6 +54,12 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function register()
|
||||
{
|
||||
// PAYONE payment service: built from merchant defaults, real Guzzle client at runtime.
|
||||
// The per-request `mode` is always passed explicitly to the service methods, so the
|
||||
// shared singleton needs only the base defaults from config.
|
||||
$this->app->singleton(PayonePaymentService::class, function () {
|
||||
return new PayonePaymentService(config('payone.defaults'));
|
||||
});
|
||||
|
||||
if ($this->app->environment() !== 'production' && class_exists(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class)) {
|
||||
$this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
|
||||
|
|
|
|||
133
app/Services/AboExternalCompletionService.php
Normal file
133
app/Services/AboExternalCompletionService.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Markiert eine angehaltene Abo-Bestellung als "extern erledigt":
|
||||
* Lieferung und Verrechnung erfolgten ausserhalb des Systems.
|
||||
*
|
||||
* Es wird KEINE Rechnung und KEIN Lieferschein erzeugt, der Status bleibt
|
||||
* unbezahlt. Die Abo-Punkte werden gutgeschrieben (als waere die Zahlung
|
||||
* eingegangen) und die Blockade des Abos wird aufgehoben, sodass es beim
|
||||
* naechsten Termin wieder regulaer ausgefuehrt wird.
|
||||
*/
|
||||
class AboExternalCompletionService
|
||||
{
|
||||
public const STATUS_EXTERN_DONE = 8;
|
||||
|
||||
private const STATUS_ABO_HOLD = 3;
|
||||
|
||||
private const STATUS_ABO_OKAY = 2;
|
||||
|
||||
/**
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
public function complete(ShoppingOrder $shoppingOrder): array
|
||||
{
|
||||
if (! $shoppingOrder->is_abo) {
|
||||
return ['success' => false, 'message' => __('abo.extern_done_only_abo')];
|
||||
}
|
||||
|
||||
$userAboOrder = UserAboOrder::where('shopping_order_id', $shoppingOrder->id)->first();
|
||||
$userAbo = $userAboOrder?->user_abo;
|
||||
|
||||
if (! $userAboOrder || ! $userAbo) {
|
||||
return ['success' => false, 'message' => __('abo.extern_done_no_order')];
|
||||
}
|
||||
|
||||
if ((int) $userAboOrder->status === self::STATUS_EXTERN_DONE) {
|
||||
return ['success' => false, 'message' => __('abo.extern_done_already')];
|
||||
}
|
||||
|
||||
if ($userAboOrder->paid || $shoppingOrder->paid) {
|
||||
return ['success' => false, 'message' => __('abo.extern_done_already_paid')];
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($shoppingOrder, $userAboOrder, $userAbo): void {
|
||||
$this->creditPoints($shoppingOrder);
|
||||
|
||||
$userAboOrder->update([
|
||||
'status' => self::STATUS_EXTERN_DONE,
|
||||
'paid' => false,
|
||||
]);
|
||||
|
||||
// Lieferung bestaetigen, Bestellung bleibt aber unbezahlt.
|
||||
$shoppingOrder->shipped = 2;
|
||||
$shoppingOrder->save();
|
||||
|
||||
// Blockade aufheben: Abo zurueck in den regulaeren Zyklus.
|
||||
$userAbo->update([
|
||||
'status' => self::STATUS_ABO_OKAY,
|
||||
'active' => true,
|
||||
'last_date' => now(),
|
||||
'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval),
|
||||
]);
|
||||
});
|
||||
|
||||
// Wie bei einer erfolgreichen Ausfuehrung: Incentive tracken und
|
||||
// Einmal-Artikel entfernen. Beide Aufrufe sind idempotent.
|
||||
IncentiveTracker::trackAboActivated($shoppingOrder);
|
||||
AboOneTimeService::purgeAfterExecution($userAbo);
|
||||
|
||||
\Log::channel('abo_order')->info('AboExternalCompletionService: Bestellung extern erledigt', [
|
||||
'abo_id' => $userAbo->id,
|
||||
'order_id' => $shoppingOrder->id,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::channel('abo_order')->error('AboExternalCompletionService: Fehler beim externen Erledigen', [
|
||||
'order_id' => $shoppingOrder->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['success' => false, 'message' => __('abo.extern_done_error', ['error' => $e->getMessage()])];
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => __('abo.extern_done_success')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erledigt die juengste angehaltene Bestellung eines Abos (Einstieg ueber die Abo-Detailseite).
|
||||
*
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
public function completeForAbo(UserAbo $userAbo): array
|
||||
{
|
||||
$shoppingOrder = $this->resolveHeldOrder($userAbo);
|
||||
|
||||
if (! $shoppingOrder) {
|
||||
return ['success' => false, 'message' => __('abo.extern_done_no_order')];
|
||||
}
|
||||
|
||||
return $this->complete($shoppingOrder);
|
||||
}
|
||||
|
||||
private function resolveHeldOrder(UserAbo $userAbo): ?ShoppingOrder
|
||||
{
|
||||
return $userAbo->user_abo_orders()
|
||||
->where('paid', false)
|
||||
->where('status', self::STATUS_ABO_HOLD)
|
||||
->orderByDesc('id')
|
||||
->first()
|
||||
?->shopping_order;
|
||||
}
|
||||
|
||||
private function creditPoints(ShoppingOrder $shoppingOrder): void
|
||||
{
|
||||
$alreadyCredited = UserSalesVolume::where('shopping_order_id', $shoppingOrder->id)->exists();
|
||||
|
||||
if ($alreadyCredited) {
|
||||
return;
|
||||
}
|
||||
|
||||
SalesPointsVolume::User($shoppingOrder);
|
||||
}
|
||||
}
|
||||
67
app/Services/Payment/Payone/DTOs/PayonePaymentResult.php
Normal file
67
app/Services/Payment/Payone/DTOs/PayonePaymentResult.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone\DTOs;
|
||||
|
||||
use App\Models\ShoppingPayment;
|
||||
|
||||
class PayonePaymentResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $rawResponse
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly array $rawResponse,
|
||||
public readonly string $txid = '',
|
||||
public readonly int $userid = 0,
|
||||
public readonly ?string $redirectUrl = null,
|
||||
public readonly ?string $errorMessage = null,
|
||||
public readonly ?string $customerMessage = null,
|
||||
public readonly ?string $errorCode = null,
|
||||
public readonly ?ShoppingPayment $shoppingPayment = null,
|
||||
) {}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === 'APPROVED';
|
||||
}
|
||||
|
||||
public function isRedirect(): bool
|
||||
{
|
||||
return $this->status === 'REDIRECT';
|
||||
}
|
||||
|
||||
public function isError(): bool
|
||||
{
|
||||
return $this->status === 'ERROR';
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->status === 'VALID';
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->rawResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $response
|
||||
*/
|
||||
public static function fromResponse(array $response, ?ShoppingPayment $shoppingPayment = null): self
|
||||
{
|
||||
return new self(
|
||||
status: $response['status'] ?? 'UNKNOWN',
|
||||
rawResponse: $response,
|
||||
txid: (string) ($response['txid'] ?? ''),
|
||||
userid: (int) ($response['userid'] ?? 0),
|
||||
redirectUrl: $response['redirecturl'] ?? null,
|
||||
errorMessage: $response['errormessage'] ?? null,
|
||||
customerMessage: $response['customermessage'] ?? null,
|
||||
errorCode: $response['errorcode'] ?? null,
|
||||
shoppingPayment: $shoppingPayment,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/Services/Payment/Payone/DTOs/PayoneWebhookResult.php
Normal file
49
app/Services/Payment/Payone/DTOs/PayoneWebhookResult.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone\DTOs;
|
||||
|
||||
class PayoneWebhookResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly string $response,
|
||||
public readonly bool $processed,
|
||||
public readonly ?string $rejectionCode = null,
|
||||
public readonly bool $duplicate = false,
|
||||
public readonly ?string $txaction = null,
|
||||
public readonly bool $paid = false,
|
||||
public readonly bool $mailSent = false,
|
||||
public readonly bool $sendLink = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Webhook was rejected before processing (validation/key/price/relation error).
|
||||
* PAYONE still receives "TSOK" so it stops retrying the malformed callback.
|
||||
*/
|
||||
public static function rejected(string $code): self
|
||||
{
|
||||
return new self(response: 'TSOK', processed: false, rejectionCode: $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook is a duplicate of an already-processed txaction; ignored.
|
||||
*/
|
||||
public static function duplicateIgnored(string $txaction): self
|
||||
{
|
||||
return new self(response: 'TSOK', processed: false, duplicate: true, txaction: $txaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook was processed: transaction recorded, status updated, side effects applied.
|
||||
*/
|
||||
public static function processed(string $txaction, bool $paid, bool $mailSent, bool $sendLink): self
|
||||
{
|
||||
return new self(
|
||||
response: 'TSOK',
|
||||
processed: true,
|
||||
txaction: $txaction,
|
||||
paid: $paid,
|
||||
mailSent: $mailSent,
|
||||
sendLink: $sendLink,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class PayoneDeclinedException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $errorCode,
|
||||
public readonly string $errorMessage,
|
||||
public readonly string $customerMessage = '',
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($errorMessage, $code, $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class PayoneTransportException extends RuntimeException {}
|
||||
79
app/Services/Payment/Payone/PaymentMethodResolver.php
Normal file
79
app/Services/Payment/Payone/PaymentMethodResolver.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PaymentMethodResolver
|
||||
{
|
||||
/**
|
||||
* Resolves a payment method string into PAYONE method parameters.
|
||||
*
|
||||
* Supported formats:
|
||||
* Simple: 'cc' | 'elv' | 'vor'
|
||||
* Compound: 'wlt#PPE' | 'wlt#PDT' | 'sb#GPY' | 'sb#PNT' | 'fnc#MIV'
|
||||
*
|
||||
* @param array<string, mixed> $context Extra data required for specific methods (e.g. cc.pseudocardpan, elv.iban).
|
||||
*
|
||||
* @throws InvalidArgumentException for unknown method strings.
|
||||
*/
|
||||
public static function resolve(string $paymentMethod, array $context = []): PayoneMethodConfig
|
||||
{
|
||||
if (str_contains($paymentMethod, '#')) {
|
||||
[$type, $subtype] = explode('#', $paymentMethod, 2);
|
||||
|
||||
return match ($type) {
|
||||
'wlt' => new PayoneMethodConfig([
|
||||
'clearingtype' => 'wlt',
|
||||
'wallettype' => $subtype,
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
]),
|
||||
'sb' => new PayoneMethodConfig([
|
||||
'clearingtype' => 'sb',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => $subtype,
|
||||
'bankcountry' => 'DE',
|
||||
'request' => 'authorization',
|
||||
]),
|
||||
'fnc' => match ($subtype) {
|
||||
'MIV' => new PayoneMethodConfig([
|
||||
'clearingtype' => 'fnc',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => 'MIV',
|
||||
'request' => 'authorization',
|
||||
]),
|
||||
default => throw new InvalidArgumentException("Unknown fnc subtype: {$subtype}"),
|
||||
},
|
||||
default => throw new InvalidArgumentException("Unknown compound payment method type: {$type}"),
|
||||
};
|
||||
}
|
||||
|
||||
return match ($paymentMethod) {
|
||||
'cc' => new PayoneMethodConfig([
|
||||
'clearingtype' => 'cc',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
'pseudocardpan' => $context['cc']['pseudocardpan'] ?? '',
|
||||
]),
|
||||
'elv' => new PayoneMethodConfig([
|
||||
'clearingtype' => 'elv',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
'mandate_identification' => $context['elv']['mandate_identification'] ?? '',
|
||||
'iban' => $context['elv']['iban'] ?? '',
|
||||
'bic' => $context['elv']['bic'] ?? '',
|
||||
'bankaccountholder' => $context['elv']['bankaccountholder'] ?? '',
|
||||
]),
|
||||
'vor' => new PayoneMethodConfig([
|
||||
'clearingtype' => 'vor',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
]),
|
||||
default => throw new InvalidArgumentException("Unknown payment method: {$paymentMethod}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Services/Payment/Payone/PayoneMethodConfig.php
Normal file
19
app/Services/Payment/Payone/PayoneMethodConfig.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone;
|
||||
|
||||
class PayoneMethodConfig
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function __construct(private readonly array $params) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->params;
|
||||
}
|
||||
}
|
||||
128
app/Services/Payment/Payone/PayonePaymentService.php
Normal file
128
app/Services/Payment/Payone/PayonePaymentService.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
|
||||
use App\Services\Payone;
|
||||
use GuzzleHttp\Client;
|
||||
|
||||
class PayonePaymentService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $defaults PAYONE merchant defaults from config('payone.defaults')
|
||||
* @param Client|null $client Optional Guzzle client; inject a MockHandler client in tests.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $defaults,
|
||||
private readonly ?Client $client = null,
|
||||
) {}
|
||||
|
||||
public function checkCreditCard(array $data, string $mode): PayonePaymentResult
|
||||
{
|
||||
$request = array_merge($this->defaults, ['mode' => $mode], [
|
||||
'request' => 'creditcardcheck',
|
||||
'cardholder' => $data['cc_cardholder_first'].' '.$data['cc_cardholder_last'],
|
||||
'cardpan' => $data['cc_cardpan'],
|
||||
'cardexpiredate' => substr($data['cc_cardexpireyear'], -2).$data['cc_cardexpiremonth'],
|
||||
'cardtype' => $data['cc_cardtype'],
|
||||
'cardcvc2' => $data['cc_cardcvc2'],
|
||||
'storecarddata' => 'yes',
|
||||
'language' => 'de',
|
||||
]);
|
||||
|
||||
return PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client));
|
||||
}
|
||||
|
||||
public function checkBankAccount(
|
||||
array $data,
|
||||
int $amount,
|
||||
string $currency,
|
||||
ShoppingUser $shoppingUser,
|
||||
string $mode,
|
||||
): PayonePaymentResult {
|
||||
$defaults = array_merge($this->defaults, ['mode' => $mode]);
|
||||
$personalData = PayoneRequestBuilder::buildPersonalData($shoppingUser);
|
||||
$methodParams = [
|
||||
'clearingtype' => 'elv',
|
||||
'request' => 'managemandate',
|
||||
'bankaccountholder' => $data['elv_bankaccountholder'],
|
||||
'iban' => $data['elv_iban'],
|
||||
'bic' => $data['elv_bic'],
|
||||
];
|
||||
$prepayment = [
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
];
|
||||
|
||||
$request = PayoneRequestBuilder::buildRequest($defaults, $personalData, [], $methodParams, $prepayment);
|
||||
|
||||
return PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client));
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges a recurring abo payment: creates the ShoppingPayment record, builds the
|
||||
* PAYONE authorization request, and returns the result with the ShoppingPayment attached.
|
||||
*/
|
||||
public function chargeAbo(
|
||||
UserAbo $userAbo,
|
||||
ShoppingOrder $shoppingOrder,
|
||||
ShoppingUser $shoppingUser,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
): PayonePaymentResult {
|
||||
$reference = substr(uniqid('m', false), 0, 16);
|
||||
|
||||
$method = [
|
||||
'clearingtype' => $userAbo->clearingtype,
|
||||
'wallettype' => $userAbo->wallettype ?? '',
|
||||
'pseudocardpan' => $userAbo->carddata['pseudocardpan'] ?? '',
|
||||
'cardexpiredate' => $userAbo->carddata['cardexpiredate'] ?? '',
|
||||
'userid' => $userAbo->payone_userid,
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
];
|
||||
|
||||
$aboInitPayment = [
|
||||
'recurrence' => 'recurring',
|
||||
'customer_is_present' => 'no',
|
||||
'request' => 'authorization',
|
||||
'amount' => $amountCents,
|
||||
];
|
||||
|
||||
$prepayment = [
|
||||
'reference' => $reference,
|
||||
'amount' => $amountCents,
|
||||
'currency' => $currency,
|
||||
'param' => $shoppingOrder->id,
|
||||
];
|
||||
|
||||
$shoppingPayment = ShoppingPayment::create([
|
||||
'shopping_order_id' => $shoppingOrder->id,
|
||||
'clearingtype' => $method['clearingtype'],
|
||||
'wallettype' => $method['wallettype'],
|
||||
'onlinebanktransfertype' => $method['onlinebanktransfertype'],
|
||||
'carddata' => $userAbo->carddata,
|
||||
'reference' => $reference,
|
||||
'amount' => $amountCents,
|
||||
'currency' => $currency,
|
||||
'is_abo' => $shoppingOrder->is_abo,
|
||||
'abo_interval' => 0,
|
||||
'mode' => $shoppingOrder->mode,
|
||||
]);
|
||||
|
||||
$defaults = array_merge($this->defaults, ['mode' => $shoppingOrder->mode]);
|
||||
$personalData = PayoneRequestBuilder::buildPersonalData($shoppingUser);
|
||||
|
||||
$request = PayoneRequestBuilder::buildRequest(
|
||||
$defaults, $personalData, [], $method, $prepayment, $aboInitPayment,
|
||||
);
|
||||
|
||||
$response = Payone::sendRequest($request, '', $this->client);
|
||||
|
||||
return PayonePaymentResult::fromResponse($response, $shoppingPayment);
|
||||
}
|
||||
}
|
||||
81
app/Services/Payment/Payone/PayoneRequestBuilder.php
Normal file
81
app/Services/Payment/Payone/PayoneRequestBuilder.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone;
|
||||
|
||||
use App\Models\ShoppingUser;
|
||||
|
||||
class PayoneRequestBuilder
|
||||
{
|
||||
/**
|
||||
* Builds the personal/billing data block for a PAYONE request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function buildPersonalData(ShoppingUser $shoppingUser): array
|
||||
{
|
||||
return [
|
||||
'firstname' => $shoppingUser->billing_firstname,
|
||||
'lastname' => $shoppingUser->billing_lastname,
|
||||
'street' => $shoppingUser->billing_address,
|
||||
'zip' => $shoppingUser->billing_zipcode,
|
||||
'city' => $shoppingUser->billing_city,
|
||||
'country' => $shoppingUser->billing_country?->code ?? 'DE',
|
||||
'email' => $shoppingUser->billing_email,
|
||||
'language' => 'DE',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the shipping/delivery data block for a PAYONE request.
|
||||
* When same_as_billing is true, billing address is used for shipping.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function buildDeliveryData(ShoppingUser $shoppingUser): array
|
||||
{
|
||||
if ($shoppingUser->same_as_billing) {
|
||||
return [
|
||||
'shipping_firstname' => $shoppingUser->billing_firstname,
|
||||
'shipping_lastname' => $shoppingUser->billing_lastname,
|
||||
'shipping_zip' => $shoppingUser->billing_zipcode,
|
||||
'shipping_city' => $shoppingUser->billing_city,
|
||||
'shipping_country' => $shoppingUser->billing_country?->code ?? 'DE',
|
||||
'shipping_street' => $shoppingUser->billing_address,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'shipping_firstname' => $shoppingUser->shipping_firstname,
|
||||
'shipping_lastname' => $shoppingUser->shipping_lastname,
|
||||
'shipping_zip' => $shoppingUser->shipping_zipcode,
|
||||
'shipping_city' => $shoppingUser->shipping_city,
|
||||
'shipping_country' => $shoppingUser->shipping_country?->code ?? 'DE',
|
||||
'shipping_street' => $shoppingUser->shipping_address,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges all PAYONE request segments into one flat array, in the correct
|
||||
* precedence order (later segments override earlier ones on key collision).
|
||||
*
|
||||
* @param array<string, mixed> $defaults
|
||||
* @param array<string, mixed> $personalData
|
||||
* @param array<string, mixed> $deliveryData
|
||||
* @param array<string, mixed> $methodParams
|
||||
* @param array<string, mixed> $prepayment
|
||||
* @param array<string, mixed> $aboInitPayment
|
||||
* @param array<string, mixed> $urls
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function buildRequest(
|
||||
array $defaults,
|
||||
array $personalData,
|
||||
array $deliveryData,
|
||||
array $methodParams,
|
||||
array $prepayment,
|
||||
array $aboInitPayment = [],
|
||||
array $urls = [],
|
||||
): array {
|
||||
return array_merge($defaults, $personalData, $deliveryData, $methodParams, $prepayment, $aboInitPayment, $urls);
|
||||
}
|
||||
}
|
||||
101
app/Services/Payment/Payone/PayoneTransactionRecorder.php
Normal file
101
app/Services/Payment/Payone/PayoneTransactionRecorder.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone;
|
||||
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\Util;
|
||||
|
||||
class PayoneTransactionRecorder
|
||||
{
|
||||
/**
|
||||
* PCI-sensitive keys that must never be persisted in transmitted_data.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const REDACTED_KEYS = ['cardpan', 'cardcvc2'];
|
||||
|
||||
/**
|
||||
* Records a PaymentTransaction from a PAYONE API response (ERROR, REDIRECT, APPROVED, …).
|
||||
*
|
||||
* @param array<string, mixed> $response
|
||||
*/
|
||||
public static function recordFromResponse(
|
||||
ShoppingPayment $payment,
|
||||
string $requestType,
|
||||
array $response,
|
||||
int $useridFallback = 0,
|
||||
): PaymentTransaction {
|
||||
return PaymentTransaction::create([
|
||||
'shopping_payment_id' => $payment->id,
|
||||
'request' => $requestType,
|
||||
'txid' => $response['txid'] ?? 0,
|
||||
'userid' => $response['userid'] ?? $useridFallback,
|
||||
'status' => $response['status'] ?? 'UNKNOWN',
|
||||
'txaction' => $response['txaction'] ?? null,
|
||||
'transmitted_data' => self::redactPayload($response),
|
||||
'errorcode' => $response['errorcode'] ?? null,
|
||||
'errormessage' => $response['errormessage'] ?? null,
|
||||
'customermessage' => $response['customermessage'] ?? null,
|
||||
'mode' => $payment->mode,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a synthetic transaction for FNC/MIV invoice payments (no PAYONE API call made).
|
||||
*
|
||||
* @param array<string, mixed> $requestPayload
|
||||
*/
|
||||
public static function recordInvoicePayment(
|
||||
ShoppingPayment $payment,
|
||||
string $requestType,
|
||||
array $requestPayload,
|
||||
): PaymentTransaction {
|
||||
return PaymentTransaction::create([
|
||||
'shopping_payment_id' => $payment->id,
|
||||
'request' => $requestType,
|
||||
'txid' => 0,
|
||||
'userid' => 0,
|
||||
'status' => 'FNCMIV',
|
||||
'transmitted_data' => self::redactPayload($requestPayload),
|
||||
'txaction' => 'invoice_open',
|
||||
'mode' => $payment->mode,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a transaction for an incoming PAYONE IPN/TSOK webhook callback.
|
||||
*
|
||||
* @param array<string, mixed> $data Raw callback payload (\Request::all()).
|
||||
*/
|
||||
public static function recordWebhookCallback(
|
||||
ShoppingPayment $payment,
|
||||
array $data,
|
||||
): PaymentTransaction {
|
||||
return PaymentTransaction::create([
|
||||
'shopping_payment_id' => $payment->id,
|
||||
'request' => 'transaction',
|
||||
'txid' => $data['txid'] ?? 0,
|
||||
'userid' => $data['userid'] ?? 0,
|
||||
'status' => 'PAYONE',
|
||||
'key' => $data['key'] ?? null,
|
||||
'txaction' => $data['txaction'] ?? null,
|
||||
'transmitted_data' => self::redactPayload(Util::utf8ize($data)),
|
||||
'mode' => $data['mode'] ?? $payment->mode,
|
||||
'errorcode' => $data['errorcode'] ?? null,
|
||||
'errormessage' => $data['errormessage'] ?? null,
|
||||
'customermessage' => $data['customermessage'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips PCI-sensitive fields before persisting a payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function redactPayload(array $payload): array
|
||||
{
|
||||
return array_diff_key($payload, array_flip(self::REDACTED_KEYS));
|
||||
}
|
||||
}
|
||||
233
app/Services/Payment/Payone/PayoneWebhookHandler.php
Normal file
233
app/Services/Payment/Payone/PayoneWebhookHandler.php
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Payone;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\MyLog;
|
||||
use App\Services\Payment;
|
||||
use App\Services\Payment\Payone\DTOs\PayoneWebhookResult;
|
||||
use App\Services\ShoppingUserService;
|
||||
use App\Services\Util;
|
||||
|
||||
class PayoneWebhookHandler
|
||||
{
|
||||
/**
|
||||
* Txaction priority (higher number = higher priority); 'paid' is the final state.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private const TXACTION_PRIORITY = [
|
||||
'appointed' => 1,
|
||||
'pending' => 2,
|
||||
'failed' => 3,
|
||||
'paid' => 10,
|
||||
];
|
||||
|
||||
/**
|
||||
* Processes a PAYONE IPN/TSOK callback. Always returns a result whose `response`
|
||||
* is sent verbatim to PAYONE. A thrown exception (paid-path transaction failure)
|
||||
* propagates so PAYONE retries the callback.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function handle(array $data): PayoneWebhookResult
|
||||
{
|
||||
if (! isset($data['key'], $data['param'], $data['userid'], $data['txid'], $data['reference'], $data['price'], $data['txaction'])) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2001 App\Http\Controllers\Api\PayoneController::paymentStatus parameter incomplete',
|
||||
$data
|
||||
);
|
||||
|
||||
return PayoneWebhookResult::rejected('2001');
|
||||
}
|
||||
|
||||
if ($data['key'] != config('payone.defaults.key')) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2002 App\Http\Controllers\Api\PayoneController::paymentStatus Key error',
|
||||
$data
|
||||
);
|
||||
|
||||
return PayoneWebhookResult::rejected('2002');
|
||||
}
|
||||
|
||||
$shopping_order = ShoppingOrder::find($data['param']);
|
||||
if (! $shopping_order) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2003 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingOrder not found:',
|
||||
$data
|
||||
);
|
||||
|
||||
return PayoneWebhookResult::rejected('2003');
|
||||
}
|
||||
|
||||
$shopping_payment = ShoppingPayment::where('reference', $data['reference'])->first();
|
||||
if (! $shopping_payment) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2004 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingPayment not found',
|
||||
$data
|
||||
);
|
||||
|
||||
return PayoneWebhookResult::rejected('2004');
|
||||
}
|
||||
|
||||
if ($shopping_payment->shopping_order_id != $shopping_order->id) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2005 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingPayment no realation ShoppingOrder',
|
||||
$data
|
||||
);
|
||||
|
||||
return PayoneWebhookResult::rejected('2005');
|
||||
}
|
||||
|
||||
$price = number_format((round($data['price'], 2) * 100), 0, '.', '');
|
||||
$price_amount = number_format($shopping_payment->amount, 0, '.', '');
|
||||
if ($price_amount != $price) {
|
||||
$data['shopping_payment-amount'] = $price_amount;
|
||||
$data['price-amount'] = $price;
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2006 App\Http\Controllers\Api\PayoneController::paymentStatus Price error',
|
||||
$data
|
||||
);
|
||||
|
||||
return PayoneWebhookResult::rejected('2006');
|
||||
}
|
||||
|
||||
/*
|
||||
* Payone sendet dieselbe txaction oft mehrfach (v. a. "appointed"). War der Status
|
||||
* bereits auf ShoppingPayment gespeichert, ist das ein Duplikat: TSOK, keine Doppel-Verarbeitung.
|
||||
* Ausnahme: erneutes "paid", obwohl die Bestellung noch nicht als bezahlt gefuehrt wird (Recovery).
|
||||
*/
|
||||
if ($shopping_payment->txaction == $data['txaction']) {
|
||||
if ($data['txaction'] === 'paid' && $shopping_order->txaction === 'paid') {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'notice',
|
||||
'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (already paid)',
|
||||
$data,
|
||||
false
|
||||
);
|
||||
|
||||
return PayoneWebhookResult::duplicateIgnored($data['txaction']);
|
||||
}
|
||||
|
||||
if (in_array($data['txaction'], ['appointed', 'failed', 'pending'], true)) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'info',
|
||||
'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (same txaction)',
|
||||
[
|
||||
'reference' => $data['reference'] ?? null,
|
||||
'param' => $data['param'] ?? null,
|
||||
'txaction' => $data['txaction'],
|
||||
'txid' => $data['txid'] ?? null,
|
||||
],
|
||||
false
|
||||
);
|
||||
|
||||
return PayoneWebhookResult::duplicateIgnored($data['txaction']);
|
||||
}
|
||||
}
|
||||
|
||||
// create transaction (centralised in the recorder, incl. PCI redaction + utf8ize)
|
||||
PayoneTransactionRecorder::recordWebhookCallback($shopping_payment, $data);
|
||||
|
||||
$current_priority = self::TXACTION_PRIORITY[$shopping_order->txaction] ?? 0;
|
||||
$new_priority = self::TXACTION_PRIORITY[$data['txaction']] ?? 0;
|
||||
|
||||
// Only update txaction if new priority is higher than current
|
||||
if ($new_priority > $current_priority) {
|
||||
$shopping_order->txaction = $data['txaction'];
|
||||
$shopping_order->save();
|
||||
$shopping_payment->txaction = $data['txaction'];
|
||||
$shopping_payment->save();
|
||||
} else {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'info',
|
||||
'App\Http\Controllers\Api\PayoneController::paymentStatus - txaction not updated (current: '.$shopping_order->txaction.' has higher/equal priority than new: '.$data['txaction'].')',
|
||||
$data,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
$send_link = false;
|
||||
$send_mail = true;
|
||||
if ($data['txaction'] === 'failed') {
|
||||
$shopping_order->setUserHistoryValue(['status' => 6]);
|
||||
Util::setInstanceStatusByPayment($shopping_payment, 5);
|
||||
}
|
||||
if ($data['txaction'] === 'appointed') {
|
||||
$shopping_order->setUserHistoryValue(['status' => 7]);
|
||||
ShoppingUserService::snycOrdersByShoppingOrder($shopping_order);
|
||||
Util::setInstanceStatusByPayment($shopping_payment, 4);
|
||||
}
|
||||
|
||||
if ($data['txaction'] === 'paid') {
|
||||
// Use DB transaction and row locking to prevent race conditions
|
||||
\DB::beginTransaction();
|
||||
try {
|
||||
// Lock the shopping order row to prevent concurrent processing
|
||||
$locked_order = ShoppingOrder::where('id', $shopping_order->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
// Double-check if payment was already processed
|
||||
if (! $locked_order->paid) {
|
||||
$send_link = Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment);
|
||||
\DB::commit();
|
||||
} else {
|
||||
$send_mail = false;
|
||||
\DB::commit();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\DB::rollBack();
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'error',
|
||||
'Error:2008 App\Http\Controllers\Api\PayoneController::paymentStatus Transaction failed',
|
||||
['error' => $e->getMessage(), 'data' => $data]
|
||||
);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $shopping_payment->id,
|
||||
txaction: 'paid',
|
||||
metadata: ['mode' => $data['mode'] ?? null, 'txid' => $data['txid'] ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
if ($data['txaction'] === 'appointed') {
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $shopping_payment->id,
|
||||
txaction: 'appointed',
|
||||
metadata: ['mode' => $data['mode'] ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
$data['send_link'] = $send_link;
|
||||
if ($send_mail) {
|
||||
Payment::paymentStatusSendMail($shopping_order, $shopping_payment, $data);
|
||||
}
|
||||
|
||||
return PayoneWebhookResult::processed(
|
||||
txaction: $data['txaction'],
|
||||
paid: $data['txaction'] === 'paid',
|
||||
mailSent: $send_mail,
|
||||
sendLink: (bool) $send_link,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Http\Controllers\Pay\PayoneController;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserHistory;
|
||||
use App\Services\Payment\Payone\PayoneTransactionRecorder;
|
||||
use Yard;
|
||||
|
||||
class PaymentHelper
|
||||
|
|
@ -39,41 +39,10 @@ class PaymentHelper
|
|||
$pay->setPersonalData();
|
||||
$response = $pay->onlyPaymentResponse();
|
||||
$shopping_payment = $pay->getShoppingPayment();
|
||||
if ($response['status'] === 'ERROR') {
|
||||
$payT = PaymentTransaction::create([
|
||||
'shopping_payment_id' => $shopping_payment->id,
|
||||
'request' => 'authorization',
|
||||
'errorcode' => $response['errorcode'],
|
||||
'errormessage' => $response['errormessage'],
|
||||
'customermessage' => $response['customermessage'],
|
||||
'status' => $response['status'],
|
||||
'mode' => $shopping_payment->mode,
|
||||
]);
|
||||
UserHistory::create(['user_id' => $user->id, 'shopping_order_id' => $shopping_order->id, 'action' => 'abo_open_payment', 'referenz' => $payT->id, 'identifier' => $user->payment_account, 'status' => 3]);
|
||||
}
|
||||
if ($response['status'] === 'REDIRECT') {
|
||||
$payT = PaymentTransaction::create([
|
||||
'shopping_payment_id' => $shopping_payment->id,
|
||||
'request' => 'authorization',
|
||||
'txid' => $response['txid'],
|
||||
'userid' => $response['userid'],
|
||||
'status' => $response['status'],
|
||||
'mode' => $shopping_payment->mode,
|
||||
|
||||
]);
|
||||
UserHistory::create(['user_id' => $user->id, 'shopping_order_id' => $shopping_order->id, 'action' => 'abo_open_payment', 'referenz' => $payT->id, 'identifier' => $user->payment_account, 'status' => 4]);
|
||||
}
|
||||
if ($response['status'] === 'APPROVED') {
|
||||
$payT = PaymentTransaction::create([
|
||||
'shopping_payment_id' => $shopping_payment->id,
|
||||
'request' => 'authorization',
|
||||
'txid' => $response['txid'],
|
||||
'userid' => $response['userid'],
|
||||
'status' => $response['status'],
|
||||
'transmitted_data' => $response,
|
||||
'mode' => $shopping_payment->mode,
|
||||
]);
|
||||
UserHistory::create(['user_id' => $user->id, 'shopping_order_id' => $shopping_order->id, 'action' => 'abo_open_payment', 'referenz' => $payT->id, 'identifier' => $user->payment_account, 'status' => 5]);
|
||||
if (in_array($response['status'] ?? '', ['ERROR', 'REDIRECT', 'APPROVED'], true)) {
|
||||
$statusMap = ['ERROR' => 3, 'REDIRECT' => 4, 'APPROVED' => 5];
|
||||
$payT = PayoneTransactionRecorder::recordFromResponse($shopping_payment, 'authorization', $response);
|
||||
UserHistory::create(['user_id' => $user->id, 'shopping_order_id' => $shopping_order->id, 'action' => 'abo_open_payment', 'referenz' => $payT->id, 'identifier' => $user->payment_account, 'status' => $statusMap[$response['status']]]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
namespace App\Services;
|
||||
|
||||
// require 'vendor/autoload.php';
|
||||
use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\BadResponseException;
|
||||
|
|
@ -86,9 +87,9 @@ class Payone
|
|||
'payone',
|
||||
'error',
|
||||
'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest HTTP-Fehlerantwort (4xx/5xx).',
|
||||
['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => $request]
|
||||
['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => self::redactRequest($request)]
|
||||
);
|
||||
abort(403, $userMessage);
|
||||
throw new PayoneTransportException($userMessage, 0, $e);
|
||||
} catch (ConnectException $e) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
|
|
@ -96,10 +97,10 @@ class Payone
|
|||
'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest Netzwerk-/Transportfehler (keine HTTP-Antwort).',
|
||||
[
|
||||
'exception' => $e->getMessage(),
|
||||
'request' => $request,
|
||||
'request' => self::redactRequest($request),
|
||||
]
|
||||
);
|
||||
abort(403, $userMessage);
|
||||
throw new PayoneTransportException($userMessage, 0, $e);
|
||||
} catch (RequestException $e) {
|
||||
if ($e->hasResponse()) {
|
||||
$error = $e->getResponse();
|
||||
|
|
@ -108,7 +109,7 @@ class Payone
|
|||
'payone',
|
||||
'error',
|
||||
'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest HTTP-Fehlerantwort.',
|
||||
['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => $request]
|
||||
['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => self::redactRequest($request)]
|
||||
);
|
||||
} else {
|
||||
MyLog::writeLog(
|
||||
|
|
@ -117,12 +118,12 @@ class Payone
|
|||
'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest Transportfehler (RequestException ohne Antwort).',
|
||||
[
|
||||
'exception' => $e->getMessage(),
|
||||
'request' => $request,
|
||||
'request' => self::redactRequest($request),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
abort(403, $userMessage);
|
||||
throw new PayoneTransportException($userMessage, 0, $e);
|
||||
}
|
||||
if (isset($response)) {
|
||||
if (implode($response->getHeader('Content-Type')) == 'text/plain;charset=UTF-8') {
|
||||
|
|
@ -137,7 +138,7 @@ class Payone
|
|||
'payone',
|
||||
'error',
|
||||
'Error: App\Services\Payone::sendRequest content type is anything else',
|
||||
['error' => $return, 'response' => $response, 'request' => $request]
|
||||
['error' => $return, 'response' => $response, 'request' => self::redactRequest($request)]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -145,7 +146,7 @@ class Payone
|
|||
'payone',
|
||||
'error',
|
||||
'Error: App\Services\Payone::sendRequest Something went wrong during the HTTP request',
|
||||
['request' => $request]
|
||||
['request' => self::redactRequest($request)]
|
||||
);
|
||||
throw new Exception('Something went wrong during the HTTP request.');
|
||||
}
|
||||
|
|
@ -157,9 +158,9 @@ class Payone
|
|||
'payone',
|
||||
'error',
|
||||
'Error: 1003 App\Http\Controllers\Pay\PayoneController::ResponseData response is non array: return:',
|
||||
['return' => $return, 'response' => $response, 'request' => $request]
|
||||
['return' => $return, 'response' => $response, 'request' => self::redactRequest($request)]
|
||||
);
|
||||
abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1003');
|
||||
throw new PayoneTransportException('Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1003');
|
||||
}
|
||||
|
||||
if (! isset($return['status'])) {
|
||||
|
|
@ -167,9 +168,9 @@ class Payone
|
|||
'payone',
|
||||
'error',
|
||||
'Error: 1004 App\Http\Controllers\Pay\PayoneController::ResponseData response has non status',
|
||||
['return' => $return, 'response' => $response, 'request' => $request]
|
||||
['return' => $return, 'response' => $response, 'request' => self::redactRequest($request)]
|
||||
);
|
||||
abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1004');
|
||||
throw new PayoneTransportException('Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1004');
|
||||
}
|
||||
|
||||
/* echo "done.\n";
|
||||
|
|
@ -179,6 +180,17 @@ class Payone
|
|||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes PCI-sensitive fields from a request array before logging.
|
||||
*
|
||||
* @param array<string, mixed> $request
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function redactRequest(array $request): array
|
||||
{
|
||||
return array_diff_key($request, array_flip(['cardpan', 'cardcvc2']));
|
||||
}
|
||||
|
||||
/**
|
||||
* gets response string an puts it into an array
|
||||
*
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
|
||||
return [
|
||||
'defaults' => [
|
||||
"aid" => '43065',//"your_account_id",
|
||||
"mid" => '42504',//"your_merchant_id",
|
||||
"portalid" => '2030693',
|
||||
"key" => hash("md5", "Zjop5cvP2UeB7Qhy"), // the key has to be hashed as md5
|
||||
"mode" => "test", // can be "live" for actual transactions
|
||||
"api_version" => "3.11",
|
||||
"encoding" => "UTF-8"
|
||||
]
|
||||
'aid' => '43065', // "your_account_id",
|
||||
'mid' => '42504', // "your_merchant_id",
|
||||
'portalid' => '2030693',
|
||||
// MD5-Hash des PAYONE-Portal-Keys (kein Klartext im Repo/Env). Pflicht: fehlt der
|
||||
// Wert, schlägt der Boot bewusst hart fehl, statt still mit einem ungültigen Key
|
||||
// (z. B. md5('')) weiterzulaufen – das würde u. a. die Webhook-Validierung aushebeln.
|
||||
'key' => env('PAYONE_KEY_HASH') ?: throw new \RuntimeException(
|
||||
'PAYONE_KEY_HASH ist nicht gesetzt. Hinterlege den MD5-Hash des PAYONE-Portal-Keys in der .env.'
|
||||
),
|
||||
'mode' => 'test', // can be "live" for actual transactions
|
||||
'api_version' => '3.11',
|
||||
'encoding' => 'UTF-8',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('shopping_payments', 'carddata')) {
|
||||
Schema::table('shopping_payments', function (Blueprint $table): void {
|
||||
$table->json('carddata')->nullable()->after('pseudocardpan');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('shopping_payments', 'carddata')) {
|
||||
Schema::table('shopping_payments', function (Blueprint $table): void {
|
||||
$table->dropColumn('carddata');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
1520
dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md
Normal file
1520
dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md
Normal file
File diff suppressed because it is too large
Load diff
80
docs/2026-06-11-test-suite-vorbestehende-fehler.md
Normal file
80
docs/2026-06-11-test-suite-vorbestehende-fehler.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Vorbestehende Test-Suite-Fehler (Diagnose)
|
||||
|
||||
**Datum:** 2026-06-11
|
||||
**Branch bei Aufnahme:** `payment/phase-r-payone-refactoring`
|
||||
**Status:** Nur dokumentiert — keine Codeänderung vorgenommen.
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
`php artisan test --compact` meldet aktuell **93 failed, 1 skipped, 448 passed**.
|
||||
Diese Fehler sind **nicht** durch die Abo-„Extern erledigt"-Funktion oder durch
|
||||
diesen Branch verursacht.
|
||||
|
||||
### Beweis, dass die Fehler von `master` geerbt sind
|
||||
|
||||
- Der einzige test-relevante Commit dieses Branches (`8593dbe`, Payment Phase R)
|
||||
ändert an `phpunit.xml` nur **eine** Zeile: `PAYONE_KEY_HASH` (DB-Config
|
||||
unberührt).
|
||||
- `DB_DATABASE=":memory:"` mit `force="true"` steht bereits auf `master`.
|
||||
- Legacy-Factory `database/factories/UserFactory.php` (`$factory->define`) und alle
|
||||
betroffenen Testdateien existieren unverändert bereits auf `master`.
|
||||
- Gegenprobe per `git stash`: dasselbe Test-Subset scheitert mit und ohne die
|
||||
Änderungen dieses Branches identisch.
|
||||
|
||||
## Vier unabhängige Ursachen
|
||||
|
||||
### 1. Legacy-Factories + fehlendes `HasFactory` (~20 Tests)
|
||||
- **Symptom:** `Call to undefined method App\User::factory()`
|
||||
- **Ursache:** `App\User` nutzt das `HasFactory`-Trait nicht; `UserFactory.php` ist
|
||||
im alten `$factory->define()`-Stil (Laravel ≤6) statt klassenbasiert
|
||||
(`extends Factory`). Die Tests rufen die neue API `User::factory()`.
|
||||
- **Betroffen:** `Unit/BusinessPlan/BusinessUserItemOptimizedTest`,
|
||||
`Unit/BusinessPlan/BusinessUserRepositoryTest`, Teile von
|
||||
`Unit/Incentive/IncentiveTrackerTest`.
|
||||
- **Fix (sicher, hoher Hebel):** `App\User` → `use HasFactory` + `newFactory()`
|
||||
(nötig, weil das Model unter `App\` statt `App\Models\` liegt);
|
||||
`UserFactory` auf `class UserFactory extends Factory { protected $model = \App\User::class; … }`
|
||||
umstellen.
|
||||
|
||||
### 2. `DatabaseTransactions` auf `:memory:`-SQLite (~32 Tests)
|
||||
- **Symptom:** `SQLSTATE … no such table: settings` (o. ä.)
|
||||
- **Ursache:** Das Trait `DatabaseTransactions` führt **keine** Migrationen aus,
|
||||
sondern setzt ein bereits migriertes Schema voraus. Bei `:memory:` startet jede
|
||||
Connection leer → Tabellen fehlen. (`RefreshDatabase`-Tests migrieren und laufen
|
||||
daher grün.)
|
||||
- **Betroffen:** `Unit/Services/InvoiceServiceTest`,
|
||||
`Feature/Payment/ConcurrentPaymentTest`, `Feature/Payment/PayoneRaceConditionTest`.
|
||||
- **Fix (Entscheidung nötig):** entweder Trait auf `RefreshDatabase` umstellen,
|
||||
**oder** Test-DB-Strategie ändern (persistente, vorab migrierte SQLite-Datei
|
||||
statt `:memory:`). Architekturentscheidung — Race-Condition-/Concurrency-Tests
|
||||
können bewusst eine persistente DB erwarten.
|
||||
|
||||
### 3. Domain-aware Routen in Unit-Tests nicht geladen (~10 Tests)
|
||||
- **Symptom:** `Route [modal_load] not defined.`
|
||||
- **Ursache:** Die Anwendung lädt Routen host-abhängig (siehe CLAUDE.md,
|
||||
Multi-Tenant Routing). In Unit-Tests ohne Host sind die `routes/domains/*`-Routen
|
||||
nicht registriert; `TreeHtmlRenderer` erzeugt aber HTML mit `route('modal_load')`.
|
||||
- **Betroffen:** `Unit/BusinessPlan/TreeHtmlRendererTest`.
|
||||
- **Fix (mittel):** Routen im Test-Setup laden bzw. den Renderer von der
|
||||
Route-Generierung entkoppeln (URL injizieren).
|
||||
|
||||
### 4. Tests veraltet ggü. refaktoriertem Konstruktor (~19 Tests)
|
||||
- **Symptom:**
|
||||
`TreeCalcBotOptimized::__construct(): Argument #4 ($forceLiveCalculation) must be of type bool, …BusinessUserRepository given`
|
||||
- **Ursache:** Die Konstruktor-Signatur von
|
||||
`App\Services\BusinessPlan\TreeCalcBotOptimized` wurde geändert; der Test
|
||||
(`TreeCalcBotOptimizedTest`) übergibt die Argumente noch in alter Reihenfolge.
|
||||
- **Betroffen:** `Unit/BusinessPlan/TreeCalcBotOptimizedTest`.
|
||||
- **Fix (mittel, reine Teständerung):** Konstruktor-Aufrufe im Test an die aktuelle
|
||||
Signatur anpassen.
|
||||
|
||||
### Rest
|
||||
Einzelne Folgefehler/Sonderfälle: `Feature/ExampleTest`,
|
||||
`Feature/FrenchLocalizationTest`, `Feature/AboOneTimeWindowTest`,
|
||||
`Feature/Incentive/IncentiveTeaserPageTest`.
|
||||
|
||||
## Empfehlung
|
||||
|
||||
Eigenständige Aufräum-Aufgabe in separatem Branch, **nicht** in den Payment-Branch
|
||||
mischen. Sinnvolle Reihenfolge nach Risiko/Nutzen: **1 → 4 → 3 → 2**
|
||||
(Ursache 2 zuletzt, da Architekturentscheidung zur Test-DB nötig).
|
||||
|
|
@ -37,5 +37,6 @@
|
|||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="BASIC_AUTH_ENABLED" value="false"/>
|
||||
<env name="PAYONE_KEY_HASH" value="698fb2555f8b2efc74f60b2121421f45"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ return [
|
|||
'abo_finish' => 'beendet',
|
||||
'abo_inactive' => 'inaktiv',
|
||||
'abo_grace' => 'kulanz',
|
||||
'abo_extern_done' => 'extern erledigt',
|
||||
'abo_info' => 'Abo Informationen',
|
||||
'info_min_duration_reached' => 'Dein Abo kann frühestens ab dem :date geändert, ergänzt, pausiert oder gekündigt werden.',
|
||||
'info_min_duration_orders_left' => 'Das Abo kann erst nach weiteren :count Ausführungen geändert, ergänzt, pausiert oder gekündigt werden.',
|
||||
|
|
@ -162,6 +163,16 @@ return [
|
|||
'retry_success' => 'Der erneute Zahlungsversuch war erfolgreich. Bestellung #:order wurde angelegt.',
|
||||
'retry_failed' => 'Der erneute Zahlungsversuch ist fehlgeschlagen. Bestellung #:order wurde angelegt. Fehler: :error',
|
||||
'retry_exception' => 'Der erneute Zahlungsversuch konnte nicht abgeschlossen werden: :error',
|
||||
'extern_done' => 'Extern erledigt',
|
||||
'extern_done_confirm_title' => 'Bestellung extern als erledigt markieren',
|
||||
'extern_done_confirm_copy' => 'Die Bestellung wird als außerhalb des Systems geliefert und verrechnet markiert. Es wird KEINE Rechnung und KEIN Lieferschein erzeugt, der Status bleibt unbezahlt. Die Abo-Punkte werden gutgeschrieben und die Blockade des Abos wird aufgehoben, sodass es beim nächsten Termin wieder normal läuft.',
|
||||
'extern_done_confirm_button' => 'Als erledigt markieren',
|
||||
'extern_done_only_abo' => 'Diese Aktion ist nur für Abo-Bestellungen möglich.',
|
||||
'extern_done_no_order' => 'Es wurde keine angehaltene Abo-Bestellung gefunden, die erledigt werden kann.',
|
||||
'extern_done_already' => 'Diese Bestellung wurde bereits als extern erledigt markiert.',
|
||||
'extern_done_already_paid' => 'Diese Bestellung ist bereits bezahlt und kann nicht extern erledigt werden.',
|
||||
'extern_done_success' => 'Die Bestellung wurde als extern erledigt markiert. Punkte gutgeschrieben, Blockade aufgehoben.',
|
||||
'extern_done_error' => 'Die Bestellung konnte nicht als erledigt markiert werden: :error',
|
||||
'team_subscriptions' => 'Team Abos',
|
||||
'team_customer_abos' => 'Team Kunden-Abos',
|
||||
'chart_monthly_abos' => 'Abos pro Monat',
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ return [
|
|||
'abo_delivery_day' => 'Abo-Liefertag',
|
||||
'abo_delivery' => 'Abo-Lieferungen',
|
||||
'active' => 'Aktiv',
|
||||
'inactive' => 'Inaktiv',
|
||||
'all' => 'Alle',
|
||||
'month' => 'Monat',
|
||||
'year' => 'Jahr',
|
||||
'is_for' => 'Ist für',
|
||||
'count' => 'Anzahl',
|
||||
'member' => 'Mitglied',
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ return [
|
|||
'abo_finish' => 'finished',
|
||||
'abo_inactive' => 'inactive',
|
||||
'abo_grace' => 'grace',
|
||||
'abo_extern_done' => 'externally completed',
|
||||
'abo_info' => 'Subscription information',
|
||||
'info_min_duration_reached' => 'Your subscription can be changed, supplemented, paused or canceled earliest from :date.',
|
||||
'info_min_duration_orders_left' => 'The subscription can only be changed, supplemented, paused or canceled after another :count executions.',
|
||||
|
|
@ -162,6 +163,16 @@ return [
|
|||
'retry_success' => 'The payment retry was successful. Order #:order was created.',
|
||||
'retry_failed' => 'The payment retry failed. Order #:order was created. Error: :error',
|
||||
'retry_exception' => 'The payment retry could not be completed: :error',
|
||||
'extern_done' => 'Externally completed',
|
||||
'extern_done_confirm_title' => 'Mark order as externally completed',
|
||||
'extern_done_confirm_copy' => 'The order will be marked as delivered and invoiced outside the system. NO invoice and NO delivery note will be created and the status stays unpaid. The subscription points will be credited and the subscription block will be lifted so it runs normally at the next date.',
|
||||
'extern_done_confirm_button' => 'Mark as completed',
|
||||
'extern_done_only_abo' => 'This action is only available for subscription orders.',
|
||||
'extern_done_no_order' => 'No held subscription order was found that can be completed.',
|
||||
'extern_done_already' => 'This order has already been marked as externally completed.',
|
||||
'extern_done_already_paid' => 'This order is already paid and cannot be completed externally.',
|
||||
'extern_done_success' => 'The order was marked as externally completed. Points credited, block lifted.',
|
||||
'extern_done_error' => 'The order could not be marked as completed: :error',
|
||||
'back' => 'back',
|
||||
'team_subscriptions' => 'Team subscriptions',
|
||||
'team_customer_abos' => 'Team Customer Subscriptions',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ return [
|
|||
'account_to' => 'account until',
|
||||
'activ' => 'active',
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'all' => 'All',
|
||||
'month' => 'Month',
|
||||
'year' => 'Year',
|
||||
'addition' => 'additive',
|
||||
'address' => 'address',
|
||||
'adviser' => 'Advisor',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@
|
|||
data-target="#modal-retry-abo-payment">
|
||||
<span class="fa fa-redo"></span> {{ __('abo.retry_payment') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success mt-3" data-toggle="modal"
|
||||
data-target="#modal-abo-extern-done">
|
||||
<span class="fa fa-check"></span> {{ __('abo.extern_done') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
|
|
|
|||
|
|
@ -124,6 +124,28 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="modal-abo-extern-done" tabindex="-1" role="dialog" aria-labelledby="modal-abo-extern-done-label" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
{!! Form::open(['action' => route('admin_abos_extern_done', [$user_abo->id])]) !!}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-abo-extern-done-label">{{ __('abo.extern_done_confirm_title') }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">{{ __('abo.extern_done_confirm_copy') }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||
<button type="submit" class="btn btn-success">{{ __('abo.extern_done_confirm_button') }}</button>
|
||||
</div>
|
||||
{!! Form::close() !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
|
|
|
|||
|
|
@ -7,40 +7,86 @@
|
|||
{{ __('Abos') }}
|
||||
</h4>
|
||||
<div class="card">
|
||||
{{--
|
||||
<div class="card-header">
|
||||
<div class="form-row align-items-center">
|
||||
<div class="col-sm-4 mb-2">
|
||||
<label class="form-label" for="filter_user_shop_id">Filter zugewiesener Berater</label>
|
||||
|
||||
<select class="selectpicker" data-style="btn-default" name="filter_member_id" id="filter_member_id" data-live-search="true">
|
||||
<option value="">Filter aus</option>
|
||||
@foreach($filter_members as $member)
|
||||
<option value="{{$member->id}}" @if(get_user_attr('filter_member_id') == $member->id) selected @endif>{{$member->first_name}} {{$member->last_name}} | {{$member->email}}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="col-10 col-sm-3 mb-2">
|
||||
<label class="form-label" for="filter_status">Filter Status</label>
|
||||
<div class="form-row align-items-end">
|
||||
<div class="col-6 col-md-3 col-lg-2 mb-2">
|
||||
<label class="form-label" for="filter_status">{{ __('tables.status') }}</label>
|
||||
<select class="custom-select" name="filter_status" id="filter_status">
|
||||
<option value="">Filter aus</option>
|
||||
@foreach(\App\Services\AboHelper::getTransStatusFilterText() as $id=>$name)
|
||||
<option value="{{$id}}" @if(get_user_attr('filter_status') == $id) selected @endif>{{$name}}</option>
|
||||
<option value="">{{ __('tables.all') }}</option>
|
||||
@foreach ($filter_statuses as $id => $name)
|
||||
<option value="{{ $id }}" @if((string) get_user_attr('filter_status') === (string) $id) selected @endif>{{ $name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-2 col-sm-1 mb-2 mt-4">
|
||||
<a href="{{ route('admin_sales_customers') }}?reset=filter" data-toggle="tooltip" data-placement="top" title="Reset Filter" class="btn icon-btn btn-sm btn-outline-dark float-right">
|
||||
<div class="col-6 col-md-3 col-lg-2 mb-2">
|
||||
<label class="form-label" for="filter_active">{{ __('tables.active') }}</label>
|
||||
<select class="custom-select" name="filter_active" id="filter_active">
|
||||
<option value="">{{ __('tables.all') }}</option>
|
||||
<option value="1" @if((string) get_user_attr('filter_active') === '1') selected @endif>{{ __('tables.active') }}</option>
|
||||
<option value="0" @if((string) get_user_attr('filter_active') === '0') selected @endif>{{ __('tables.inactive') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-lg-2 mb-2">
|
||||
<label class="form-label" for="filter_is_for">{{ __('tables.is_for') }}</label>
|
||||
<select class="custom-select" name="filter_is_for" id="filter_is_for">
|
||||
<option value="">{{ __('tables.all') }}</option>
|
||||
<option value="me" @if(get_user_attr('filter_is_for') === 'me') selected @endif>{{ __('tables.adviser') }}</option>
|
||||
<option value="ot" @if(get_user_attr('filter_is_for') === 'ot') selected @endif>{{ __('tables.customer') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-lg-2 mb-2">
|
||||
<label class="form-label" for="filter_payment">{{ __('tables.payment') }}</label>
|
||||
<select class="custom-select" name="filter_payment" id="filter_payment">
|
||||
<option value="">{{ __('tables.all') }}</option>
|
||||
<option value="cc" @if(get_user_attr('filter_payment') === 'cc') selected @endif>{{ __('payment.credit_card') }}</option>
|
||||
<option value="wlt" @if(get_user_attr('filter_payment') === 'wlt') selected @endif>{{ __('payment.paypal') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row align-items-end">
|
||||
<div class="col-12 col-md-6 col-lg-4 mb-2">
|
||||
<label class="form-label">{{ __('tables.start_date') }}</label>
|
||||
<div class="d-flex border rounded p-1" style="gap: 6px;">
|
||||
<select class="custom-select" name="filter_start_month" id="filter_start_month" aria-label="{{ __('tables.month') }}">
|
||||
<option value="">{{ __('tables.month') }} ({{ __('tables.all') }})</option>
|
||||
@foreach ($filter_months as $num => $name)
|
||||
<option value="{{ $num }}" @if((string) get_user_attr('filter_start_month') === (string) $num) selected @endif>{{ $name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select class="custom-select" name="filter_start_year" id="filter_start_year" aria-label="{{ __('tables.year') }}" style="max-width: 130px;">
|
||||
<option value="">{{ __('tables.year') }} ({{ __('tables.all') }})</option>
|
||||
@foreach ($filter_years as $year)
|
||||
<option value="{{ $year }}" @if((string) get_user_attr('filter_start_year') === (string) $year) selected @endif>{{ $year }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-lg-2 mb-2">
|
||||
<label class="form-label" for="filter_next_day">{{ __('tables.next_date') }}</label>
|
||||
<select class="custom-select" name="filter_next_day" id="filter_next_day">
|
||||
<option value="">{{ __('tables.all') }}</option>
|
||||
@foreach ($filter_next_days as $day)
|
||||
<option value="{{ $day }}" @if((string) get_user_attr('filter_next_day') === (string) $day) selected @endif>{{ \App\Services\HTMLHelper::getAboStrLang($day) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-md-3 col-lg-3 mb-2">
|
||||
<label class="form-label" for="filter_member_id">{{ __('tables.adviser') }}</label>
|
||||
<select class="selectpicker" data-style="btn-default" name="filter_member_id" id="filter_member_id" data-live-search="true" title="{{ __('tables.all') }}">
|
||||
<option value="">{{ __('tables.all') }}</option>
|
||||
@foreach ($filter_members as $member)
|
||||
<option value="{{ $member->id }}" @if(get_user_attr('filter_member_id') == $member->id) selected @endif>{{ $member->first_name }} {{ $member->last_name }} | {{ $member->email }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-lg-auto mb-2 d-flex align-items-end">
|
||||
<a href="{{ route('admin_abos') }}?reset=filter" title="{{ __('Reset Filter') }}" class="btn icon-btn btn-sm btn-outline-dark">
|
||||
<span class="fa fa-sync"></span>
|
||||
</a>
|
||||
<span class="ml-2">{{ __('Reset Filter') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
--}}
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="datatable-abos table table-striped table-bordered" id="datatable-abos">
|
||||
<thead>
|
||||
|
|
@ -73,10 +119,15 @@
|
|||
"serverSide": true,
|
||||
ajax: {
|
||||
url: '{!! route( 'admin_abos_datatable') !!}',
|
||||
data: function(d) {
|
||||
data: function(d) {
|
||||
d.filter_member_id = $('select[name=filter_member_id]').val();
|
||||
d.filter_user_shop_id = $('select[name=filter_user_shop_id]').val();
|
||||
d.filter_status = $('select[name=filter_status]').val();
|
||||
d.filter_active = $('select[name=filter_active]').val();
|
||||
d.filter_is_for = $('select[name=filter_is_for]').val();
|
||||
d.filter_payment = $('select[name=filter_payment]').val();
|
||||
d.filter_start_month = $('select[name=filter_start_month]').val();
|
||||
d.filter_start_year = $('select[name=filter_start_year]').val();
|
||||
d.filter_next_day = $('select[name=filter_next_day]').val();
|
||||
}
|
||||
},
|
||||
"order": [[0, "desc" ]],
|
||||
|
|
@ -104,13 +155,7 @@
|
|||
"url": "/js/datatables-{{ \App::getLocale() }}.json"
|
||||
}
|
||||
});
|
||||
$('#filter_member_id').on('change', function(){
|
||||
oTable.draw();
|
||||
});
|
||||
$('#filter_user_shop_id').on('change', function(){
|
||||
oTable.draw();
|
||||
});
|
||||
$('#filter_txaction').on('change', function(){
|
||||
$('#filter_start_month, #filter_start_year, #filter_next_day, #filter_status, #filter_active, #filter_is_for, #filter_payment, #filter_member_id').on('change', function(){
|
||||
oTable.draw();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
@php
|
||||
$aboOrder = $shopping_order->is_abo
|
||||
? \App\Models\UserAboOrder::where('shopping_order_id', $shopping_order->id)->first()
|
||||
: null;
|
||||
$isExternDone = $aboOrder && (int) $aboOrder->status === \App\Services\AboExternalCompletionService::STATUS_EXTERN_DONE;
|
||||
$canExternDone = $aboOrder && ! $isExternDone && ! $aboOrder->paid && ! $shopping_order->paid;
|
||||
@endphp
|
||||
<div class="card">
|
||||
<!-- Status -->
|
||||
<div class="card-body">
|
||||
|
|
@ -38,6 +45,23 @@
|
|||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if ($isAdmin && $aboOrder)
|
||||
<div class="col-md-3 mb-1">
|
||||
<strong class="mr-2">{{ __('navigation.abo') }}:</strong>
|
||||
<div class="d-flex flex-wrap align-items-center mt-1" style="gap: 6px;">
|
||||
@if ($isExternDone)
|
||||
<span class="badge badge-pill badge-success">
|
||||
<span class="fa fa-check"></span> {{ __('abo.abo_extern_done') }}
|
||||
</span>
|
||||
@elseif ($canExternDone)
|
||||
<button type="button" class="btn btn-sm btn-success" data-toggle="modal"
|
||||
data-target="#modals-abo-extern-done">
|
||||
<span class="fa fa-check"></span> {{ __('abo.extern_done') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="col-md-4 mb-2">
|
||||
<strong class="mr-2">{{ __('order.invoice') }}:</strong>
|
||||
<div class="d-flex flex-wrap align-items-center mt-1" style="gap: 6px;">
|
||||
|
|
@ -732,6 +756,30 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if ($isAdmin && $canExternDone)
|
||||
<div class="modal fade" id="modals-abo-extern-done">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="{{ route('admin_sales_customers_detail', [$shopping_order->id]) }}"
|
||||
method="post">
|
||||
@csrf
|
||||
<input type="hidden" name="id" value="{{ $shopping_order->id }}">
|
||||
<input type="hidden" name="action" value="shopping-order-abo-extern-done">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('abo.extern_done_confirm_title') }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">{{ __('abo.extern_done_confirm_copy') }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||
<button type="submit" class="btn btn-success">{{ __('abo.extern_done_confirm_button') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($isAdmin)
|
||||
<div class="modal fade" id="modals-shipped">
|
||||
<div class="modal-dialog">
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
|||
Route::post('/admin/abos/update/{id}', 'Admin\AboController@update')->name('admin_abos_update');
|
||||
Route::post('/admin/abos/rollback/{id}', 'Admin\AboController@rollback')->name('admin_abos_rollback');
|
||||
Route::post('/admin/abos/retry-payment/{id}', 'Admin\AboController@retryPayment')->name('admin_abos_retry_payment');
|
||||
Route::post('/admin/abos/extern-done/{id}', 'Admin\AboController@externDone')->name('admin_abos_extern_done');
|
||||
Route::get('/admin/abos/datatable', 'Admin\AboController@datatable')->name('admin_abos_datatable');
|
||||
|
||||
// incentives
|
||||
|
|
|
|||
231
tests/Feature/AboExternalCompletionTest.php
Normal file
231
tests/Feature/AboExternalCompletionTest.php
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Incentive;
|
||||
use App\Models\IncentiveNewAbo;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\AboExternalCompletionService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function heldAboSeed(): array
|
||||
{
|
||||
$country = Country::create([
|
||||
'code' => 'DE',
|
||||
'phone' => '49',
|
||||
'en' => 'Germany',
|
||||
'de' => 'Deutschland',
|
||||
'es' => 'Alemania',
|
||||
'fr' => 'Allemagne',
|
||||
'it' => 'Germania',
|
||||
'ru' => 'Германия',
|
||||
'eu_country' => true,
|
||||
]);
|
||||
|
||||
$account = UserAccount::create([
|
||||
'first_name' => 'Klaus',
|
||||
'last_name' => 'Kunde',
|
||||
'address' => 'Hauptstrasse 1',
|
||||
'zipcode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'country_id' => $country->id,
|
||||
]);
|
||||
|
||||
$user = User::forceCreate([
|
||||
'account_id' => $account->id,
|
||||
'email' => 'klaus@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $user->id,
|
||||
'billing_firstname' => 'Klaus',
|
||||
'billing_lastname' => 'Kunde',
|
||||
'billing_address' => 'Hauptstrasse 1',
|
||||
'billing_zipcode' => '10115',
|
||||
'billing_city' => 'Berlin',
|
||||
'billing_country_id' => $country->id,
|
||||
'billing_email' => $user->email,
|
||||
'same_as_billing' => true,
|
||||
'shipping_firstname' => 'Klaus',
|
||||
'shipping_lastname' => 'Kunde',
|
||||
'shipping_address' => 'Hauptstrasse 1',
|
||||
'shipping_zipcode' => '10115',
|
||||
'shipping_city' => 'Berlin',
|
||||
'shipping_country_id' => $country->id,
|
||||
'is_for' => 'me',
|
||||
'is_from' => 'user_order',
|
||||
'mode' => 'live',
|
||||
]);
|
||||
|
||||
$userAbo = UserAbo::create([
|
||||
'user_id' => $user->id,
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'is_for' => 'me',
|
||||
'email' => $user->email,
|
||||
'payone_userid' => 654321,
|
||||
'clearingtype' => 'cc',
|
||||
'active' => true,
|
||||
'status' => 3, // abo_hold (Blockade)
|
||||
'abo_interval' => 5,
|
||||
'next_date' => now()->subDays(2), // ueberfaellig
|
||||
]);
|
||||
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => $user->id,
|
||||
'country_id' => $country->id,
|
||||
'language' => 'de',
|
||||
'user_shop_id' => 1,
|
||||
'payment_for' => 6,
|
||||
'total' => 50.00,
|
||||
'subtotal' => 50.00,
|
||||
'points' => 25,
|
||||
'paid' => false,
|
||||
'shipped' => 0,
|
||||
'is_abo' => true,
|
||||
'abo_interval' => 5,
|
||||
'txaction' => 'error',
|
||||
'mode' => 'live',
|
||||
]);
|
||||
|
||||
$aboOrder = UserAboOrder::create([
|
||||
'user_abo_id' => $userAbo->id,
|
||||
'shopping_order_id' => $order->id,
|
||||
'status' => 3, // abo_hold
|
||||
'paid' => false,
|
||||
]);
|
||||
|
||||
return [$userAbo, $order, $aboOrder, $user];
|
||||
}
|
||||
|
||||
it('markiert eine angehaltene Abo-Bestellung als extern erledigt, schreibt Punkte gut und hebt die Blockade auf', function () {
|
||||
[$userAbo, $order, $aboOrder, $user] = heldAboSeed();
|
||||
|
||||
// Vorher zaehlt die angehaltene Lieferung nicht als ausgefuehrte Lieferung.
|
||||
expect(UserAbo::find($userAbo->id)->getCountPaidOrders())->toBe(0);
|
||||
|
||||
$result = app(AboExternalCompletionService::class)->complete($order);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
|
||||
$order->refresh();
|
||||
$aboOrder->refresh();
|
||||
$userAbo->refresh();
|
||||
|
||||
// Bestellung bleibt unbezahlt, keine Rechnung
|
||||
expect((bool) $order->paid)->toBeFalse()
|
||||
->and((int) $order->shipped)->toBe(2)
|
||||
->and($order->isInvoice())->toBeFalse();
|
||||
|
||||
// Abo-Bestellung: gruener Status 8, weiterhin unbezahlt
|
||||
expect((int) $aboOrder->status)->toBe(8)
|
||||
->and((bool) $aboOrder->paid)->toBeFalse();
|
||||
|
||||
// Blockade aufgehoben: zurueck auf okay, aktiv, next_date in der Zukunft
|
||||
expect((int) $userAbo->status)->toBe(2)
|
||||
->and($userAbo->active)->toBeTrue();
|
||||
expect(\Carbon\Carbon::parse($userAbo->getRawOriginal('next_date'))->isFuture())->toBeTrue();
|
||||
|
||||
// Abo-Lieferungs-Zaehler beruecksichtigt die extern erledigte Ausfuehrung.
|
||||
$fresh = UserAbo::find($userAbo->id);
|
||||
expect($fresh->getCountPaidOrders())->toBe(1)
|
||||
->and($fresh->getCountOrders())->toBe(1);
|
||||
|
||||
// Punkte gutgeschrieben - mit heutigem Datum (Markierungsdatum), nicht dem
|
||||
// vergangenen Bestelldatum, damit sie in der laufenden Monatsauswertung landen.
|
||||
$volume = UserSalesVolume::where('shopping_order_id', $order->id)->first();
|
||||
expect($volume)->not->toBeNull()
|
||||
->and((float) $volume->points)->toBe(25.0)
|
||||
->and((int) $volume->user_id)->toBe($user->id)
|
||||
->and((int) $volume->month)->toBe((int) date('m'))
|
||||
->and((int) $volume->year)->toBe((int) date('Y'))
|
||||
->and($volume->date)->toBe(date('d.m.Y'));
|
||||
});
|
||||
|
||||
it('trackt Incentive-Punkte mit dem Bestelldatum (Ausfuehrungsmonat), nicht mit heute', function () {
|
||||
\Carbon\Carbon::setTestNow('2026-06-15');
|
||||
|
||||
[$userAbo, $order, , $user] = heldAboSeed();
|
||||
|
||||
// Die vorgesehene Ausfuehrung lag im April; das Incentive-Fenster deckt nur
|
||||
// April ab (nicht Juni/heute). Wuerde der Tracker "heute" verwenden, faende
|
||||
// er das Incentive nicht und legte keinen IncentiveNewAbo-Eintrag an.
|
||||
$executionDate = \Carbon\Carbon::parse('2026-04-10');
|
||||
ShoppingOrder::where('id', $order->id)->update(['created_at' => $executionDate]);
|
||||
|
||||
Incentive::factory()->active()->create([
|
||||
'qualification_start' => '2026-04-01',
|
||||
'qualification_end' => '2026-04-30',
|
||||
'calculation_end' => '2026-05-31',
|
||||
]);
|
||||
|
||||
app(AboExternalCompletionService::class)->complete($order->fresh());
|
||||
|
||||
// Incentive-Abo wurde dem April-Fenster zugeordnet, activated_at = Bestelldatum.
|
||||
$newAbo = IncentiveNewAbo::where('user_abo_id', $userAbo->id)->first();
|
||||
expect($newAbo)->not->toBeNull()
|
||||
->and($newAbo->activated_at->toDateString())->toBe('2026-04-10');
|
||||
|
||||
// Incentive-Zaehler (qualified_abos) des Teilnehmers wurde erhoeht.
|
||||
$participant = \App\Models\IncentiveParticipant::find($newAbo->participant_id);
|
||||
expect((int) $participant->qualified_abos)->toBe(1);
|
||||
|
||||
// Reguläre Provisionspunkte hingegen mit heutigem Datum (Juni).
|
||||
$volume = UserSalesVolume::where('shopping_order_id', $order->id)->first();
|
||||
expect((int) $volume->month)->toBe(6)
|
||||
->and((int) $volume->year)->toBe(2026);
|
||||
|
||||
\Carbon\Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('ist idempotent und schreibt Punkte nicht doppelt gut', function () {
|
||||
[, $order] = heldAboSeed();
|
||||
|
||||
$service = app(AboExternalCompletionService::class);
|
||||
$first = $service->complete($order);
|
||||
$second = $service->complete($order->fresh());
|
||||
|
||||
expect($first['success'])->toBeTrue()
|
||||
->and($second['success'])->toBeFalse();
|
||||
|
||||
expect(UserSalesVolume::where('shopping_order_id', $order->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('verweigert die Aktion fuer eine bereits bezahlte Abo-Bestellung', function () {
|
||||
[, $order, $aboOrder] = heldAboSeed();
|
||||
$aboOrder->update(['paid' => true]);
|
||||
|
||||
$result = app(AboExternalCompletionService::class)->complete($order->fresh());
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect(UserSalesVolume::where('shopping_order_id', $order->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('verweigert die Aktion fuer eine Nicht-Abo-Bestellung', function () {
|
||||
[, $order] = heldAboSeed();
|
||||
$order->update(['is_abo' => false]);
|
||||
|
||||
$result = app(AboExternalCompletionService::class)->complete($order->fresh());
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('erledigt ueber die Abo-Detailseite die juengste angehaltene Bestellung', function () {
|
||||
[$userAbo, $order] = heldAboSeed();
|
||||
|
||||
$result = app(AboExternalCompletionService::class)->completeForAbo($userAbo);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect((int) UserAboOrder::where('shopping_order_id', $order->id)->value('status'))->toBe(8);
|
||||
expect((int) $userAbo->fresh()->status)->toBe(2);
|
||||
});
|
||||
19
tests/Feature/PayoneHashKeyCommandTest.php
Normal file
19
tests/Feature/PayoneHashKeyCommandTest.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('gibt den korrekten MD5-Hash für den PAYONE-Portal-Key aus', function (): void {
|
||||
$this->artisan('payone:hash-key')
|
||||
->expectsQuestion('PAYONE-Portal-Key (Klartext, verdeckte Eingabe)', 'Zjop5cvP2UeB7Qhy')
|
||||
->expectsOutputToContain('698fb2555f8b2efc74f60b2121421f45')
|
||||
->assertExitCode(0);
|
||||
});
|
||||
|
||||
it('hasht beliebige Eingaben deterministisch (md5)', function (): void {
|
||||
$this->artisan('payone:hash-key')
|
||||
->expectsQuestion('PAYONE-Portal-Key (Klartext, verdeckte Eingabe)', 'hello')
|
||||
->expectsOutputToContain(hash('md5', 'hello'))
|
||||
->assertExitCode(0);
|
||||
});
|
||||
225
tests/Feature/PayonePaymentServiceTest.php
Normal file
225
tests/Feature/PayonePaymentServiceTest.php
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
|
||||
use App\Services\Payment\Payone\PayonePaymentService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makePayoneClient(string $status, array $extra = []): Client
|
||||
{
|
||||
$pairs = array_merge(['status' => $status], $extra);
|
||||
$body = implode("\n", array_map(fn ($v, $k) => "$k=$v", $pairs, array_keys($pairs)));
|
||||
$mock = new MockHandler([new Response(200, ['Content-Type' => 'text/plain;charset=UTF-8'], $body)]);
|
||||
|
||||
return new Client(['handler' => HandlerStack::create($mock)]);
|
||||
}
|
||||
|
||||
function makeService(Client $client): PayonePaymentService
|
||||
{
|
||||
return new PayonePaymentService(
|
||||
defaults: ['mid' => '1', 'aid' => '1', 'portalid' => '1', 'key' => 'abc', 'api_version' => '3.11'],
|
||||
client: $client,
|
||||
);
|
||||
}
|
||||
|
||||
// --- PayonePaymentResult DTO ---
|
||||
|
||||
it('fromResponse baut ein vollständiges Ergebnis-DTO', function (): void {
|
||||
$raw = [
|
||||
'status' => 'APPROVED',
|
||||
'txid' => '12345',
|
||||
'userid' => '67890',
|
||||
'redirecturl' => null,
|
||||
'errormessage' => null,
|
||||
'customermessage' => null,
|
||||
'errorcode' => null,
|
||||
];
|
||||
|
||||
$result = PayonePaymentResult::fromResponse($raw);
|
||||
|
||||
expect($result->status)->toBe('APPROVED')
|
||||
->and($result->txid)->toBe('12345')
|
||||
->and($result->userid)->toEqual(67890)
|
||||
->and($result->isApproved())->toBeTrue()
|
||||
->and($result->isError())->toBeFalse()
|
||||
->and($result->isRedirect())->toBeFalse()
|
||||
->and($result->toArray())->toBe($raw);
|
||||
});
|
||||
|
||||
it('fromResponse setzt UNKNOWN bei fehlendem Status', function (): void {
|
||||
$result = PayonePaymentResult::fromResponse([]);
|
||||
|
||||
expect($result->status)->toBe('UNKNOWN')
|
||||
->and($result->txid)->toBe('')
|
||||
->and($result->userid)->toEqual(0);
|
||||
});
|
||||
|
||||
it('isValid erkennt VALID-Status (creditcardcheck)', function (): void {
|
||||
$result = PayonePaymentResult::fromResponse(['status' => 'VALID', 'pseudocardpan' => 'PSEUDO1']);
|
||||
|
||||
expect($result->isValid())->toBeTrue()
|
||||
->and($result->isApproved())->toBeFalse();
|
||||
});
|
||||
|
||||
// --- checkCreditCard ---
|
||||
|
||||
it('checkCreditCard gibt PayonePaymentResult mit VALID zurück', function (): void {
|
||||
$client = makePayoneClient('VALID', ['pseudocardpan' => 'PSEUDO999']);
|
||||
$service = makeService($client);
|
||||
|
||||
$result = $service->checkCreditCard([
|
||||
'cc_cardholder_first' => 'Max',
|
||||
'cc_cardholder_last' => 'Mustermann',
|
||||
'cc_cardpan' => '4111111111111111',
|
||||
'cc_cardexpireyear' => '2026',
|
||||
'cc_cardexpiremonth' => '12',
|
||||
'cc_cardtype' => 'V',
|
||||
'cc_cardcvc2' => '123',
|
||||
], 'test');
|
||||
|
||||
expect($result)->toBeInstanceOf(PayonePaymentResult::class)
|
||||
->and($result->status)->toBe('VALID')
|
||||
->and($result->isValid())->toBeTrue()
|
||||
->and($result->rawResponse['pseudocardpan'])->toBe('PSEUDO999');
|
||||
});
|
||||
|
||||
it('checkCreditCard gibt ERROR-Ergebnis bei abgelehnter Karte zurück', function (): void {
|
||||
$client = makePayoneClient('ERROR', ['errorcode' => '863', 'errormessage' => 'Card declined']);
|
||||
$service = makeService($client);
|
||||
|
||||
$result = $service->checkCreditCard([
|
||||
'cc_cardholder_first' => 'Max',
|
||||
'cc_cardholder_last' => 'Mustermann',
|
||||
'cc_cardpan' => '4111111111111111',
|
||||
'cc_cardexpireyear' => '2025',
|
||||
'cc_cardexpiremonth' => '01',
|
||||
'cc_cardtype' => 'V',
|
||||
'cc_cardcvc2' => '000',
|
||||
], 'test');
|
||||
|
||||
expect($result->isError())->toBeTrue()
|
||||
->and($result->errorCode)->toBe('863')
|
||||
->and($result->errorMessage)->toBe('Card declined');
|
||||
});
|
||||
|
||||
// --- chargeAbo ---
|
||||
|
||||
it('chargeAbo erstellt ShoppingPayment und gibt APPROVED zurück', function (): void {
|
||||
$client = makePayoneClient('APPROVED', ['txid' => '77777', 'userid' => '12345']);
|
||||
$service = makeService($client);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => 1,
|
||||
'billing_firstname' => 'Max',
|
||||
'billing_lastname' => 'Mustermann',
|
||||
'billing_address' => 'Musterstraße 1',
|
||||
'billing_zipcode' => '12345',
|
||||
'billing_city' => 'Musterstadt',
|
||||
'billing_country_id' => 1,
|
||||
'shipping_country_id' => 1,
|
||||
'billing_email' => 'max@example.com',
|
||||
'same_as_billing' => true,
|
||||
'mode' => 'test',
|
||||
'wp_order_number' => 0,
|
||||
]);
|
||||
|
||||
$shoppingOrder = ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => 1,
|
||||
'country_id' => 1,
|
||||
'user_shop_id' => 1,
|
||||
'mode' => 'test',
|
||||
'is_abo' => 1,
|
||||
'abo_interval' => 1,
|
||||
'total_shipping' => '99.00',
|
||||
]);
|
||||
|
||||
$userAbo = UserAbo::create([
|
||||
'user_id' => 1,
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'clearingtype' => 'cc',
|
||||
'wallettype' => '',
|
||||
'payone_userid' => 99,
|
||||
'carddata' => ['pseudocardpan' => 'PSEUDO123', 'cardexpiredate' => '2612'],
|
||||
'abo_interval' => 1,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$result = $service->chargeAbo($userAbo, $shoppingOrder, $shoppingUser, 9900, 'EUR');
|
||||
|
||||
expect($result)->toBeInstanceOf(PayonePaymentResult::class)
|
||||
->and($result->isApproved())->toBeTrue()
|
||||
->and($result->txid)->toBe('77777')
|
||||
->and($result->userid)->toEqual(12345)
|
||||
->and($result->shoppingPayment)->toBeInstanceOf(ShoppingPayment::class)
|
||||
->and($result->shoppingPayment->clearingtype)->toBe('cc')
|
||||
->and($result->shoppingPayment->amount)->toEqual(9900)
|
||||
->and($result->shoppingPayment->currency)->toBe('EUR')
|
||||
->and($result->shoppingPayment->is_abo)->toBeTruthy()
|
||||
->and($result->shoppingPayment->mode)->toBe('test');
|
||||
|
||||
expect(ShoppingPayment::count())->toEqual(1);
|
||||
});
|
||||
|
||||
it('chargeAbo gibt ERROR-Ergebnis zurück bei abgelehnter Abbuchung', function (): void {
|
||||
$client = makePayoneClient('ERROR', ['errorcode' => '307', 'errormessage' => 'Transaction declined']);
|
||||
$service = makeService($client);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => 1,
|
||||
'billing_firstname' => 'Anna',
|
||||
'billing_lastname' => 'Test',
|
||||
'billing_address' => 'Testweg 5',
|
||||
'billing_zipcode' => '54321',
|
||||
'billing_city' => 'Testort',
|
||||
'billing_country_id' => 1,
|
||||
'shipping_country_id' => 1,
|
||||
'billing_email' => 'anna@test.com',
|
||||
'same_as_billing' => true,
|
||||
'mode' => 'test',
|
||||
'wp_order_number' => 0,
|
||||
]);
|
||||
|
||||
$shoppingOrder = ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => 1,
|
||||
'country_id' => 1,
|
||||
'user_shop_id' => 1,
|
||||
'mode' => 'live',
|
||||
'is_abo' => 1,
|
||||
'abo_interval' => 1,
|
||||
'total_shipping' => '49.50',
|
||||
]);
|
||||
|
||||
$userAbo = UserAbo::create([
|
||||
'user_id' => 1,
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'clearingtype' => 'cc',
|
||||
'wallettype' => '',
|
||||
'payone_userid' => 42,
|
||||
'carddata' => ['pseudocardpan' => 'PSEUDO456'],
|
||||
'abo_interval' => 1,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$result = $service->chargeAbo($userAbo, $shoppingOrder, $shoppingUser, 4950, 'EUR');
|
||||
|
||||
expect($result->isError())->toBeTrue()
|
||||
->and($result->errorCode)->toBe('307')
|
||||
->and($result->shoppingPayment)->toBeInstanceOf(ShoppingPayment::class)
|
||||
->and($result->shoppingPayment->mode)->toBe('live');
|
||||
|
||||
// ShoppingPayment wird auch bei ERROR erstellt (für Protokollierung)
|
||||
expect(ShoppingPayment::count())->toEqual(1);
|
||||
});
|
||||
134
tests/Feature/PayoneTransactionRecorderTest.php
Normal file
134
tests/Feature/PayoneTransactionRecorderTest.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\Payment\Payone\PayoneTransactionRecorder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
// SQLite in-memory: FK constraints not enforced, so shopping_order_id = 0 is fine here.
|
||||
$this->payment = ShoppingPayment::create([
|
||||
'shopping_order_id' => 0,
|
||||
'reference' => 'test-'.uniqid(),
|
||||
'amount' => 9900,
|
||||
'currency' => 'EUR',
|
||||
'clearingtype' => 'cc',
|
||||
'mode' => 'test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('records an APPROVED response with txid and userid', function (): void {
|
||||
$response = [
|
||||
'status' => 'APPROVED',
|
||||
'txid' => '12345',
|
||||
'userid' => '67890',
|
||||
];
|
||||
|
||||
$transaction = PayoneTransactionRecorder::recordFromResponse($this->payment, 'authorization', $response);
|
||||
|
||||
expect($transaction)->toBeInstanceOf(PaymentTransaction::class)
|
||||
->and($transaction->shopping_payment_id)->toBe($this->payment->id)
|
||||
->and($transaction->request)->toBe('authorization')
|
||||
->and($transaction->status)->toBe('APPROVED')
|
||||
->and($transaction->txid)->toBe('12345')
|
||||
->and($transaction->userid)->toBe('67890')
|
||||
->and($transaction->mode)->toBe('test')
|
||||
->and($transaction->errorcode)->toBeNull()
|
||||
->and($transaction->transmitted_data)->toMatchArray($response);
|
||||
});
|
||||
|
||||
it('records an ERROR response with error fields and no txid', function (): void {
|
||||
$response = [
|
||||
'status' => 'ERROR',
|
||||
'errorcode' => '909',
|
||||
'errormessage' => 'Card declined',
|
||||
'customermessage' => 'Ihre Karte wurde abgelehnt.',
|
||||
];
|
||||
|
||||
$transaction = PayoneTransactionRecorder::recordFromResponse($this->payment, 'authorization', $response);
|
||||
|
||||
expect($transaction->status)->toBe('ERROR')
|
||||
->and($transaction->txid)->toEqual(0)
|
||||
->and($transaction->userid)->toEqual(0)
|
||||
->and($transaction->errorcode)->toBe('909')
|
||||
->and($transaction->errormessage)->toBe('Card declined')
|
||||
->and($transaction->customermessage)->toBe('Ihre Karte wurde abgelehnt.')
|
||||
->and($transaction->transmitted_data)->toMatchArray($response);
|
||||
});
|
||||
|
||||
it('records a REDIRECT response', function (): void {
|
||||
$response = [
|
||||
'status' => 'REDIRECT',
|
||||
'txid' => '99999',
|
||||
'userid' => '11111',
|
||||
'redirecturl' => 'https://paypal.com/redirect',
|
||||
];
|
||||
|
||||
$transaction = PayoneTransactionRecorder::recordFromResponse($this->payment, 'authorization', $response);
|
||||
|
||||
expect($transaction->status)->toBe('REDIRECT')
|
||||
->and($transaction->txid)->toBe('99999')
|
||||
->and($transaction->userid)->toBe('11111');
|
||||
});
|
||||
|
||||
it('falls back to useridFallback when response has no userid', function (): void {
|
||||
$response = ['status' => 'APPROVED', 'txid' => '555'];
|
||||
|
||||
$transaction = PayoneTransactionRecorder::recordFromResponse($this->payment, 'authorization', $response, 42);
|
||||
|
||||
expect($transaction->userid)->toEqual(42);
|
||||
});
|
||||
|
||||
it('records an invoice payment with synthetic FNCMIV status', function (): void {
|
||||
$requestPayload = ['clearingtype' => 'fnc', 'onlinebanktransfertype' => 'MIV', 'amount' => 9900];
|
||||
|
||||
$transaction = PayoneTransactionRecorder::recordInvoicePayment($this->payment, 'authorization', $requestPayload);
|
||||
|
||||
expect($transaction)->toBeInstanceOf(PaymentTransaction::class)
|
||||
->and($transaction->status)->toBe('FNCMIV')
|
||||
->and($transaction->txaction)->toBe('invoice_open')
|
||||
->and($transaction->txid)->toEqual(0)
|
||||
->and($transaction->userid)->toEqual(0)
|
||||
->and($transaction->mode)->toBe('test')
|
||||
->and($transaction->transmitted_data)->toMatchArray($requestPayload);
|
||||
});
|
||||
|
||||
it('records a webhook callback with PAYONE status and stored fields', function (): void {
|
||||
$data = [
|
||||
'key' => 'somekey',
|
||||
'txid' => '321623031',
|
||||
'userid' => '158723953',
|
||||
'txaction' => 'paid',
|
||||
'price' => '89.00',
|
||||
'errorcode' => '0',
|
||||
];
|
||||
|
||||
$transaction = PayoneTransactionRecorder::recordWebhookCallback($this->payment, $data);
|
||||
|
||||
expect($transaction)->toBeInstanceOf(PaymentTransaction::class)
|
||||
->and($transaction->request)->toBe('transaction')
|
||||
->and($transaction->status)->toBe('PAYONE')
|
||||
->and($transaction->txid)->toBe('321623031')
|
||||
->and($transaction->userid)->toBe('158723953')
|
||||
->and($transaction->txaction)->toBe('paid')
|
||||
->and($transaction->mode)->toBe('test') // falls back to payment->mode when data has none
|
||||
->and($transaction->transmitted_data)->toMatchArray(['txaction' => 'paid', 'price' => '89.00']);
|
||||
});
|
||||
|
||||
it('strips cardpan and cardcvc2 from stored transmitted_data', function (): void {
|
||||
$payload = [
|
||||
'clearingtype' => 'cc',
|
||||
'cardpan' => '4111111111111111',
|
||||
'cardcvc2' => '123',
|
||||
'amount' => 9900,
|
||||
];
|
||||
|
||||
$transaction = PayoneTransactionRecorder::recordInvoicePayment($this->payment, 'authorization', $payload);
|
||||
|
||||
expect($transaction->transmitted_data)->not->toHaveKey('cardpan')
|
||||
->and($transaction->transmitted_data)->not->toHaveKey('cardcvc2')
|
||||
->and($transaction->transmitted_data)->toMatchArray(['clearingtype' => 'cc', 'amount' => 9900]);
|
||||
});
|
||||
23
tests/Feature/PayoneTransportExceptionHandlingTest.php
Normal file
23
tests/Feature/PayoneTransportExceptionHandlingTest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\Handler;
|
||||
use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
|
||||
use Illuminate\Http\Request;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('renders an uncaught PayoneTransportException as a 403 response', function (): void {
|
||||
$handler = app(Handler::class);
|
||||
$request = Request::create('/checkout', 'POST');
|
||||
|
||||
$response = $handler->render($request, new PayoneTransportException('Fehlercode: 1002'));
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('does not report PayoneTransportException (no duplicate exception mail)', function (): void {
|
||||
$handler = app(Handler::class);
|
||||
|
||||
expect($handler->shouldReport(new PayoneTransportException('x')))->toBeFalse();
|
||||
});
|
||||
257
tests/Feature/PayoneWebhookHandlerTest.php
Normal file
257
tests/Feature/PayoneWebhookHandlerTest.php
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\Payment\Payone\DTOs\PayoneWebhookResult;
|
||||
use App\Services\Payment\Payone\PayoneWebhookHandler;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a matching order + payment for an 89.00 EUR transaction.
|
||||
*
|
||||
* @return array{0: ShoppingOrder, 1: ShoppingPayment}
|
||||
*/
|
||||
function webhookBootstrap(array $orderOverrides = [], array $paymentOverrides = []): array
|
||||
{
|
||||
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
|
||||
|
||||
$shopOwner = User::forceCreate([
|
||||
'email' => 'wh-owner-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$userShop = UserShop::first() ?? UserShop::create([
|
||||
'user_id' => $shopOwner->id,
|
||||
'name' => 'WH Shop',
|
||||
'slug' => 'wh-shop-'.uniqid(),
|
||||
'domain' => 'wh-test.local',
|
||||
]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $shopOwner->id,
|
||||
'billing_firstname' => 'Web',
|
||||
'billing_lastname' => 'Hook',
|
||||
'billing_email' => 'webhook-'.uniqid().'@example.com',
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
]);
|
||||
|
||||
$order = ShoppingOrder::create(array_merge([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => $shopOwner->id,
|
||||
'country_id' => $country->id,
|
||||
'user_shop_id' => $userShop->id,
|
||||
'total' => 89.00,
|
||||
'paid' => false,
|
||||
'txaction' => null,
|
||||
'mode' => 'test',
|
||||
], $orderOverrides));
|
||||
|
||||
// txaction is not fillable on ShoppingPayment; apply it explicitly after create.
|
||||
$paymentTxaction = $paymentOverrides['txaction'] ?? null;
|
||||
unset($paymentOverrides['txaction']);
|
||||
|
||||
$payment = ShoppingPayment::create(array_merge([
|
||||
'shopping_order_id' => $order->id,
|
||||
'reference' => 'wh-ref-'.uniqid(),
|
||||
'amount' => 8900,
|
||||
'clearingtype' => 'cc',
|
||||
'currency' => 'EUR',
|
||||
'mode' => 'test',
|
||||
], $paymentOverrides));
|
||||
|
||||
if ($paymentTxaction !== null) {
|
||||
$payment->txaction = $paymentTxaction;
|
||||
$payment->save();
|
||||
}
|
||||
|
||||
return [$order, $payment];
|
||||
}
|
||||
|
||||
function validCallback(ShoppingOrder $order, ShoppingPayment $payment, string $txaction): array
|
||||
{
|
||||
return [
|
||||
'key' => config('payone.defaults.key'),
|
||||
'txaction' => $txaction,
|
||||
'clearingtype' => 'cc',
|
||||
'userid' => '158723953',
|
||||
'txid' => '321623031',
|
||||
'price' => '89.00',
|
||||
'param' => $order->id,
|
||||
'reference' => $payment->reference,
|
||||
'mode' => 'test',
|
||||
];
|
||||
}
|
||||
|
||||
// --- Validation / rejection paths ---
|
||||
|
||||
it('rejects an incomplete callback with code 2001', function (): void {
|
||||
$result = (new PayoneWebhookHandler)->handle(['txaction' => 'paid']);
|
||||
|
||||
expect($result)->toBeInstanceOf(PayoneWebhookResult::class)
|
||||
->and($result->response)->toBe('TSOK')
|
||||
->and($result->processed)->toBeFalse()
|
||||
->and($result->rejectionCode)->toBe('2001');
|
||||
});
|
||||
|
||||
it('rejects a callback missing txaction with code 2001', function (): void {
|
||||
[$order, $payment] = webhookBootstrap();
|
||||
$data = validCallback($order, $payment, 'paid');
|
||||
unset($data['txaction']);
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->processed)->toBeFalse()
|
||||
->and($result->rejectionCode)->toBe('2001');
|
||||
});
|
||||
|
||||
it('rejects a callback with a wrong key (2002)', function (): void {
|
||||
[$order, $payment] = webhookBootstrap();
|
||||
$data = validCallback($order, $payment, 'paid');
|
||||
$data['key'] = 'totally-wrong-key';
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->processed)->toBeFalse()
|
||||
->and($result->rejectionCode)->toBe('2002');
|
||||
});
|
||||
|
||||
it('rejects when the shopping order is not found (2003)', function (): void {
|
||||
[$order, $payment] = webhookBootstrap();
|
||||
$data = validCallback($order, $payment, 'paid');
|
||||
$data['param'] = 999999;
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->rejectionCode)->toBe('2003');
|
||||
});
|
||||
|
||||
it('rejects when the shopping payment is not found (2004)', function (): void {
|
||||
[$order, $payment] = webhookBootstrap();
|
||||
$data = validCallback($order, $payment, 'paid');
|
||||
$data['reference'] = 'does-not-exist';
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->rejectionCode)->toBe('2004');
|
||||
});
|
||||
|
||||
it('rejects when payment does not belong to the order (2005)', function (): void {
|
||||
[$order, $payment] = webhookBootstrap();
|
||||
[$otherOrder] = webhookBootstrap();
|
||||
$data = validCallback($order, $payment, 'paid');
|
||||
// Point param at a different existing order than the payment's order.
|
||||
$data['param'] = $otherOrder->id;
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->rejectionCode)->toBe('2005');
|
||||
});
|
||||
|
||||
it('rejects on a price mismatch (2006)', function (): void {
|
||||
[$order, $payment] = webhookBootstrap(paymentOverrides: ['amount' => 5000]);
|
||||
$data = validCallback($order, $payment, 'paid'); // price 89.00 -> 8900 != 5000
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->rejectionCode)->toBe('2006');
|
||||
});
|
||||
|
||||
// --- Duplicate handling ---
|
||||
|
||||
it('ignores a duplicate paid callback when already paid', function (): void {
|
||||
[$order, $payment] = webhookBootstrap(
|
||||
orderOverrides: ['txaction' => 'paid', 'paid' => true],
|
||||
paymentOverrides: ['txaction' => 'paid'],
|
||||
);
|
||||
$data = validCallback($order, $payment, 'paid');
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->processed)->toBeFalse()
|
||||
->and($result->duplicate)->toBeTrue()
|
||||
->and($result->txaction)->toBe('paid');
|
||||
|
||||
// No new transaction recorded for the ignored duplicate.
|
||||
expect(PaymentTransaction::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a duplicate appointed callback with same txaction', function (): void {
|
||||
[$order, $payment] = webhookBootstrap(
|
||||
orderOverrides: ['txaction' => 'appointed'],
|
||||
paymentOverrides: ['txaction' => 'appointed'],
|
||||
);
|
||||
$data = validCallback($order, $payment, 'appointed');
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->duplicate)->toBeTrue()
|
||||
->and($result->txaction)->toBe('appointed');
|
||||
expect(PaymentTransaction::count())->toBe(0);
|
||||
});
|
||||
|
||||
// --- Processing paths ---
|
||||
|
||||
it('processes an appointed callback: records transaction and updates txaction', function (): void {
|
||||
[$order, $payment] = webhookBootstrap();
|
||||
$data = validCallback($order, $payment, 'appointed');
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->processed)->toBeTrue()
|
||||
->and($result->txaction)->toBe('appointed')
|
||||
->and($result->paid)->toBeFalse();
|
||||
|
||||
$order->refresh();
|
||||
$payment->refresh();
|
||||
expect($order->txaction)->toBe('appointed')
|
||||
->and($payment->txaction)->toBe('appointed')
|
||||
->and(PaymentTransaction::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('does not downgrade txaction when a lower-priority callback arrives', function (): void {
|
||||
// Order already 'paid' (priority 10), incoming 'appointed' (priority 1) must not override.
|
||||
[$order, $payment] = webhookBootstrap(
|
||||
orderOverrides: ['txaction' => 'paid', 'paid' => true],
|
||||
paymentOverrides: ['txaction' => 'pending'],
|
||||
);
|
||||
$data = validCallback($order, $payment, 'appointed');
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->processed)->toBeTrue();
|
||||
$order->refresh();
|
||||
expect($order->txaction)->toBe('paid'); // unchanged
|
||||
// Transaction still recorded even though txaction was not updated.
|
||||
expect(PaymentTransaction::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('processes a paid callback: marks order paid and records transaction', function (): void {
|
||||
[$order, $payment] = webhookBootstrap();
|
||||
$data = validCallback($order, $payment, 'paid');
|
||||
|
||||
$result = (new PayoneWebhookHandler)->handle($data);
|
||||
|
||||
expect($result->processed)->toBeTrue()
|
||||
->and($result->paid)->toBeTrue()
|
||||
->and($result->mailSent)->toBeTrue();
|
||||
|
||||
$order->refresh();
|
||||
expect((bool) $order->paid)->toBeTrue()
|
||||
->and($order->txaction)->toBe('paid')
|
||||
->and(PaymentTransaction::count())->toBe(1);
|
||||
});
|
||||
115
tests/Unit/Services/Payment/PaymentMethodResolverTest.php
Normal file
115
tests/Unit/Services/Payment/PaymentMethodResolverTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Payment\Payone\PaymentMethodResolver;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
// --- Simple methods ---
|
||||
|
||||
it('resolves cc with pseudocardpan from context', function (): void {
|
||||
$config = PaymentMethodResolver::resolve('cc', ['cc' => ['pseudocardpan' => 'PSEUDO123']]);
|
||||
|
||||
expect($config->toArray())->toMatchArray([
|
||||
'clearingtype' => 'cc',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
'pseudocardpan' => 'PSEUDO123',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves vor with no context needed', function (): void {
|
||||
$config = PaymentMethodResolver::resolve('vor');
|
||||
|
||||
expect($config->toArray())->toMatchArray([
|
||||
'clearingtype' => 'vor',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves elv with bank data from context', function (): void {
|
||||
$ctx = [
|
||||
'elv' => [
|
||||
'mandate_identification' => 'MAND001',
|
||||
'iban' => 'DE89370400440532013000',
|
||||
'bic' => 'COBADEFFXXX',
|
||||
'bankaccountholder' => 'Max Mustermann',
|
||||
],
|
||||
];
|
||||
|
||||
$config = PaymentMethodResolver::resolve('elv', $ctx);
|
||||
|
||||
expect($config->toArray())->toMatchArray([
|
||||
'clearingtype' => 'elv',
|
||||
'request' => 'authorization',
|
||||
'iban' => 'DE89370400440532013000',
|
||||
'bic' => 'COBADEFFXXX',
|
||||
'mandate_identification' => 'MAND001',
|
||||
'bankaccountholder' => 'Max Mustermann',
|
||||
]);
|
||||
});
|
||||
|
||||
// --- Compound methods ---
|
||||
|
||||
it('resolves wlt#PPE for PayPal via PAYONE', function (): void {
|
||||
$config = PaymentMethodResolver::resolve('wlt#PPE');
|
||||
|
||||
expect($config->toArray())->toMatchArray([
|
||||
'clearingtype' => 'wlt',
|
||||
'wallettype' => 'PPE',
|
||||
'onlinebanktransfertype' => '',
|
||||
'request' => 'authorization',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves wlt#PDT for paydirekt', function (): void {
|
||||
$config = PaymentMethodResolver::resolve('wlt#PDT');
|
||||
|
||||
expect($config->toArray()['wallettype'])->toBe('PDT');
|
||||
});
|
||||
|
||||
it('resolves sb#PNT for Sofort', function (): void {
|
||||
$config = PaymentMethodResolver::resolve('sb#PNT');
|
||||
|
||||
expect($config->toArray())->toMatchArray([
|
||||
'clearingtype' => 'sb',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => 'PNT',
|
||||
'bankcountry' => 'DE',
|
||||
'request' => 'authorization',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves sb#GPY for giropay', function (): void {
|
||||
$config = PaymentMethodResolver::resolve('sb#GPY');
|
||||
|
||||
expect($config->toArray()['onlinebanktransfertype'])->toBe('GPY');
|
||||
});
|
||||
|
||||
it('resolves fnc#MIV for MIVITA invoice', function (): void {
|
||||
$config = PaymentMethodResolver::resolve('fnc#MIV');
|
||||
|
||||
expect($config->toArray())->toMatchArray([
|
||||
'clearingtype' => 'fnc',
|
||||
'wallettype' => '',
|
||||
'onlinebanktransfertype' => 'MIV',
|
||||
'request' => 'authorization',
|
||||
]);
|
||||
});
|
||||
|
||||
// --- Error cases ---
|
||||
|
||||
it('throws for unknown simple payment method', function (): void {
|
||||
PaymentMethodResolver::resolve('unknown');
|
||||
})->throws(InvalidArgumentException::class, 'Unknown payment method: unknown');
|
||||
|
||||
it('throws for unknown compound type', function (): void {
|
||||
PaymentMethodResolver::resolve('xyz#FOO');
|
||||
})->throws(InvalidArgumentException::class, 'Unknown compound payment method type: xyz');
|
||||
|
||||
it('throws for unknown fnc subtype', function (): void {
|
||||
PaymentMethodResolver::resolve('fnc#PYV');
|
||||
})->throws(InvalidArgumentException::class, 'Unknown fnc subtype: PYV');
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
|
||||
use App\Services\Payone;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
|
|
@ -8,7 +9,6 @@ use GuzzleHttp\HandlerStack;
|
|||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
|
@ -47,7 +47,7 @@ it('sendRequest liefert das geparste Array bei text/plain;charset=UTF-8', functi
|
|||
->and($result['txid'])->toBe('999');
|
||||
});
|
||||
|
||||
it('sendRequest wandelt Netzwerkfehler ohne HTTP-Antwort in Fehler 1002 um', function () {
|
||||
it('sendRequest wandelt Netzwerkfehler ohne HTTP-Antwort in PayoneTransportException (1002) um', function () {
|
||||
Mail::fake();
|
||||
|
||||
$mockRequest = new Request('POST', Payone::PAYONE_SERVER_API_URL);
|
||||
|
|
@ -59,15 +59,33 @@ it('sendRequest wandelt Netzwerkfehler ohne HTTP-Antwort in Fehler 1002 um', fun
|
|||
$thrown = null;
|
||||
try {
|
||||
Payone::sendRequest(['request' => 'authorization'], '', $client);
|
||||
} catch (HttpException $e) {
|
||||
} catch (PayoneTransportException $e) {
|
||||
$thrown = $e;
|
||||
}
|
||||
|
||||
expect($thrown)->toBeInstanceOf(HttpException::class)
|
||||
->and($thrown->getStatusCode())->toBe(403)
|
||||
expect($thrown)->toBeInstanceOf(PayoneTransportException::class)
|
||||
->and($thrown->getMessage())->toContain('Fehlercode: 1002');
|
||||
});
|
||||
|
||||
it('sendRequest wirft PayoneTransportException (1004) wenn die Antwort keinen Status enthält', function () {
|
||||
Mail::fake();
|
||||
|
||||
$mock = new MockHandler([
|
||||
new Response(200, ['Content-Type' => 'text/plain;charset=UTF-8'], "foo=bar\nbaz=qux\n"),
|
||||
]);
|
||||
$client = new Client(['handler' => HandlerStack::create($mock)]);
|
||||
|
||||
$thrown = null;
|
||||
try {
|
||||
Payone::sendRequest(['request' => 'authorization'], '', $client);
|
||||
} catch (PayoneTransportException $e) {
|
||||
$thrown = $e;
|
||||
}
|
||||
|
||||
expect($thrown)->toBeInstanceOf(PayoneTransportException::class)
|
||||
->and($thrown->getMessage())->toContain('Fehlercode: 1004');
|
||||
});
|
||||
|
||||
it('exponiert die erwartete Post-Gateway-URL', function () {
|
||||
expect(Payone::PAYONE_SERVER_API_URL)->toBe('https://api.pay1.de/post-gateway/');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue