Abo: Doppelbestellung verhindern, Payment-Link loeschen & Pending-Status

Verhindert das Anlegen eines zweiten Abos (eigenes oder Kundenabo) wenn
bereits eines initiiert ist bzw. ein offener Abo-Payment-Link existiert
(redirectIfAboAlreadyInitiated). Payment-Links koennen jetzt geloescht
werden, abgesichert ueber canDeleteInstance + Shop-Zugehoerigkeit, mit
Erfolgs-/Fehler-Flash. Kundenabo-Uebersicht zeigt den Pending-Initial-
Payment-Status (wartend / in Bearbeitung / fehlgeschlagen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-06-25 13:37:30 +00:00
parent e401251b0d
commit c175a1c077
7 changed files with 386 additions and 23 deletions

View file

@ -151,12 +151,17 @@ class OrderController extends Controller
}
if (Request::get('action') === 'next') {
$selectedFor = Request::get('switchers-radio-is-for');
if ($this->redirectIfAboAlreadyInitiated($selectedFor, $user, $delivery_id)) {
return redirect(route('user_order_my_delivery', [$for, $delivery_id]));
}
Yard::instance('shopping')->destroy();
if (strpos(Request::get('switchers-radio-is-for'), 'ot') !== false) {
if (strpos($selectedFor, 'ot') !== false) {
$delivery_id = $id;
}
return redirect(route('user_order_my_list', [Request::get('switchers-radio-is-for'), $delivery_id]));
return redirect(route('user_order_my_list', [$selectedFor, $delivery_id]));
}
return view('user.order.delivery', [
@ -172,9 +177,8 @@ class OrderController extends Controller
{
$user = User::find(Auth::user()->id);
if ($for === 'abo-me' && AboHelper::userHasAbo($user)) {
Log::channel(self::LOG_CHANNEL)->error("User #{$user->id} attempted to create abo but already has one");
abort(403, 'User has an Abo. Cannot order.');
if ($this->redirectIfAboAlreadyInitiated($for, $user, $id)) {
return redirect(route('user_order_my_delivery', [$for, $id]));
}
$shopping_user = null;
@ -227,6 +231,10 @@ class OrderController extends Controller
$data = Request::all();
$user = User::find(Auth::user()->id);
if ($this->redirectIfAboAlreadyInitiated($for, $user, $id)) {
return redirect(route('user_order_my_delivery', [$for, $id]));
}
$rules = [
'shipping_salutation' => 'required',
'shipping_firstname' => 'required',
@ -336,6 +344,48 @@ class OrderController extends Controller
return redirect(route('user_order_my_custom_payment', ['identifier' => $identifier]));
}
private function redirectIfAboAlreadyInitiated(?string $for, User $user, $deliveryId = null): bool
{
if ($for === 'abo-me' && AboHelper::userHasAbo($user)) {
Log::channel(self::LOG_CHANNEL)->warning("User #{$user->id} attempted to create abo but already has one initiated");
\Session()->flash('custom-error', __('abo.error_user_has_initiated_abo'));
return true;
}
if ($for === 'abo-ot-customer' && $deliveryId) {
$shoppingUser = Shop::checkShoppingUser($deliveryId, $user);
if (AboHelper::hasAboByEmail($shoppingUser->billing_email) || $this->hasOpenAboPaymentLink($user, $shoppingUser->billing_email)) {
Log::channel(self::LOG_CHANNEL)->warning("User #{$user->id} attempted to create customer abo but one is already initiated", [
'shopping_user_id' => $shoppingUser->id,
'email' => $shoppingUser->billing_email,
]);
\Session()->flash('custom-error', __('abo.error_email_has_abo', ['email' => $shoppingUser->billing_email]));
return true;
}
}
return false;
}
private function hasOpenAboPaymentLink(User $user, string $email): bool
{
$userShopId = $user->shop ? $user->shop->id : null;
if (! $userShopId) {
return false;
}
return ShoppingInstance::where('user_shop_id', $userShopId)
->where('payment', 6)
->where('status', '<', 10)
->get()
->contains(function (ShoppingInstance $shoppingInstance) use ($email) {
return OrderPaymentService::isAboCustomerPaymentLink($shoppingInstance)
&& strcasecmp((string) ($shoppingInstance->shopping_data['billing_email'] ?? ''), $email) === 0;
});
}
/**
* Process payment for user orders
*/

View file

@ -1,18 +1,14 @@
<?php
namespace App\Http\Controllers\User;
use Request;
use App\User;
use App\Models\ShoppingInstance;
use App\Http\Controllers\Controller;
use App\Models\ShoppingInstance;
use App\Services\OrderPaymentService;
use App\User;
class OrderPaymentController extends Controller
{
public function __construct()
{
$this->middleware('active.account');
@ -22,6 +18,7 @@ class OrderPaymentController extends Controller
{
$data = [
];
return view('user.order.payment.index', $data);
}
@ -29,15 +26,34 @@ class OrderPaymentController extends Controller
{
$data = OrderPaymentService::getCustomPayment($identifier);
$data['backlink'] = route('user_order_payment_links');
return view('user.order.payment.custom_payment', $data);
}
public function delete($identifier){
public function delete($identifier)
{
$shoppingInstance = ShoppingInstance::where('identifier', $identifier)->firstOrFail();
$user = User::find(\Auth::user()->id);
$userShopId = $user->shop ? $user->shop->id : null;
if ((int) $shoppingInstance->user_shop_id !== (int) $userShopId) {
abort(403);
}
if (! OrderPaymentService::canDeleteInstance($shoppingInstance)) {
\Session()->flash('alert-error', __('abo.payment_link_delete_not_allowed'));
return redirect(route('user_order_payment_links'));
}
OrderPaymentService::deleteInstance($identifier);
\Session()->flash('alert-success', __('abo.payment_link_delete_success'));
return redirect(route('user_order_payment_links'));
}
public function datatable(){
public function datatable()
{
$user = User::find(\Auth::user()->id);
$user_shop_id = $user->shop ? $user->shop->id : null;
@ -50,7 +66,7 @@ class OrderPaymentController extends Controller
return '<a href="'.route('user_order_payment_links_detail', [$shoppingInstance->identifier]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
})
->addColumn('created_at', function (ShoppingInstance $shoppingInstance) {
return $shoppingInstance->created_at->format("d.m.Y");
return $shoppingInstance->created_at->format('d.m.Y');
})
->addColumn('status', function (ShoppingInstance $shoppingInstance) {
return OrderPaymentService::getStatusBadge($shoppingInstance);
@ -59,9 +75,9 @@ class OrderPaymentController extends Controller
return $shoppingInstance->payment_method ? $shoppingInstance->payment_method->name : '-';
})
->addColumn('total', function (ShoppingInstance $shoppingInstance) {
if($shoppingInstance->amount > 0){
return '<span class="no-line-break">'.$shoppingInstance->getAmountFormatted()." €</span>";
}else{
if ($shoppingInstance->amount > 0) {
return '<span class="no-line-break">'.$shoppingInstance->getAmountFormatted().' €</span>';
} else {
return '-';
}
})
@ -78,12 +94,16 @@ class OrderPaymentController extends Controller
return $shoppingInstance->shopping_data['billing_email'] ?? '-';
})
->addColumn('delete', function (ShoppingInstance $shoppingInstance) {
if (! OrderPaymentService::canDeleteInstance($shoppingInstance)) {
return '';
}
return '<a onclick="return confirm(\''.__('confirm_delete').'\');" href="'.route('user_order_payment_links_delete', [$shoppingInstance->identifier]).'" class="btn icon-btn btn-sm btn-danger"><span class="fa fa-trash"></span></a>';
})
->orderColumn('id', 'identifier $1')
->orderColumn('created_at', 'created_at $1')
->orderColumn('status', 'status $1')
->orderColumn('status', 'status $1')
->orderColumn('total', 'total $1')
->orderColumn('type', 'type $1')
->orderColumn('billing_firstname', 'billing_firstname $1')
@ -92,6 +112,4 @@ class OrderPaymentController extends Controller
->rawColumns(['id', 'status', 'type', 'total', 'invoice', 'delete'])
->make(true);
}
}
}

View file

@ -4,6 +4,8 @@ namespace App\Services;
use App\Models\ShoppingInstance;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\User;
use Yard;
class OrderPaymentService
@ -21,6 +23,52 @@ class OrderPaymentService
}*/
}
public static function isAboCustomerPaymentLink(ShoppingInstance $shoppingInstance): bool
{
return (int) $shoppingInstance->payment === 6
&& ($shoppingInstance->shopping_data['is_for'] ?? null) === 'abo-ot-customer'
&& (bool) ($shoppingInstance->shopping_data['is_abo'] ?? false);
}
public static function canDeleteInstance(ShoppingInstance $shoppingInstance): bool
{
return in_array((int) $shoppingInstance->status, [0, 1, 5, 6], true);
}
public static function isPaymentProcessing(ShoppingInstance $shoppingInstance): bool
{
return in_array((int) $shoppingInstance->status, [2, 3, 4], true);
}
public static function isPaymentFailed(ShoppingInstance $shoppingInstance): bool
{
return in_array((int) $shoppingInstance->status, [5, 6], true);
}
public static function hasUserAboForPaymentLink(ShoppingInstance $shoppingInstance, User $user): bool
{
$shoppingUserId = (int) ($shoppingInstance->shopping_user_id ?: ($shoppingInstance->shopping_data['shopping_user_id'] ?? 0));
$email = trim((string) ($shoppingInstance->shopping_data['billing_email'] ?? ''));
if ($shoppingUserId <= 0 && $email === '') {
return false;
}
return UserAbo::where('member_id', $user->id)
->where('is_for', 'ot')
->where('status', '>=', 1)
->where(function ($query) use ($shoppingUserId, $email) {
if ($shoppingUserId > 0) {
$query->orWhere('shopping_user_id', $shoppingUserId);
}
if ($email !== '') {
$query->orWhereRaw('LOWER(email) = ?', [mb_strtolower($email)]);
}
})
->exists();
}
public static function updateInstanceStatus($identifier, $status, $lower = true)
{
if (! ShoppingInstance::where('identifier', $identifier)->exists()) {

View file

@ -5,6 +5,17 @@
{{ __('navigation.payment_links') }} / {{ __('navigation.overview') }}
</h4>
@if (Session::has('alert-success'))
<div class="alert alert-success">
{{ Session::get('alert-success') }}
</div>
@endif
@if (Session::has('alert-error'))
<div class="alert alert-danger">
{{ Session::get('alert-error') }}
</div>
@endif
{{-- Status-Legende --}}
<div class="card mb-3">
<div class="card-body">
@ -68,4 +79,4 @@
});
</script>
@endsection
@endsection

View file

@ -106,6 +106,17 @@
<span class="badge badge-outline-secondary">
{{ __('tables.customer') }} #{{ $index + 1 }}
</span>
@if($abo->isPendingInitialPayment())
<div class="small text-warning mt-1">
@if($abo->hasFailedInitialPayment())
{{ __('abo.pending_payment_failed_title') }}
@elseif($abo->hasInitialPaymentStatus())
{{ __('abo.pending_payment_processing_title') }}
@else
{{ __('abo.pending_payment_waiting_title') }}
@endif
</div>
@endif
</td>
<td>
<strong class="text-primary">

View file

@ -163,6 +163,8 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::post('/user/abos/update/{view}/{id}', 'User\AboController@update')->name('user_abos_update');
Route::post('/user/abos/onetime/{view}/{id}', 'User\AboController@oneTime')->name('user_abos_onetime');
Route::post('/user/abos/retry-payment/{view}/{id}', 'User\AboController@retryPayment')->name('user_abos_retry_payment');
Route::post('/user/abos/delete-pending/{view}/{id}', 'User\AboController@deletePending')->name('user_abos_delete_pending');
Route::post('/user/abos/payment-link/delete/{identifier}', 'User\AboController@deletePaymentLink')->name('user_abos_payment_link_delete');
Route::get('/user/abo/datatable/{id}', 'User\AboController@datatable')->name('user_abo_datatable');
Route::get('/user/abo/onetime-datatable/{id}', 'User\AboController@oneTimeDatatable')->name('user_abo_onetime_datatable');
// Route to show team subscriptions (Abos)

View file

@ -0,0 +1,223 @@
<?php
use App\Models\Country;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingInstance;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserShop;
use App\Services\AboHelper;
use App\Services\OrderPaymentService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('behandelt neue noch nicht bestaetigte Eigen-Abos als vorhandenes Abo', function (): void {
$fixture = createPendingInitialAboFixture();
expect(AboHelper::userHasAbo($fixture['user']))->toBeTrue()
->and($fixture['userAbo']->isPendingInitialPayment())->toBeTrue()
->and($fixture['userAbo']->hasInitialPaymentStatus())->toBeFalse()
->and($fixture['userAbo']->canDeletePendingInitialPayment())->toBeFalse();
});
it('behandelt neue Kunden-Abos anhand der E-Mail als vorhandenes Abo', function (): void {
$fixture = createPendingInitialAboFixture(['is_for' => 'ot']);
expect(AboHelper::hasAboByEmail($fixture['shoppingUser']->billing_email))->toBeTrue()
->and(AboHelper::memberHasAbo($fixture['shoppingUser']))->toBeTrue();
});
it('erlaubt das Loeschen eines neuen Abo-Eintrags erst bei fehlgeschlagener Erstzahlung', function (): void {
$fixture = createPendingInitialAboFixture(paymentOverrides: ['txaction' => 'failed']);
expect($fixture['userAbo']->fresh()->hasInitialPaymentStatus())->toBeTrue()
->and($fixture['userAbo']->fresh()->hasFailedInitialPayment())->toBeTrue()
->and($fixture['userAbo']->fresh()->canDeletePendingInitialPayment())->toBeTrue();
});
it('erlaubt Abo-Zahlungslink-Loeschung nur vor Zahlung oder nach Fehler', function (): void {
foreach ([0, 1, 5, 6] as $status) {
$instance = new ShoppingInstance(['status' => $status]);
expect(OrderPaymentService::canDeleteInstance($instance))->toBeTrue();
}
foreach ([2, 3, 4, 10] as $status) {
$instance = new ShoppingInstance(['status' => $status]);
expect(OrderPaymentService::canDeleteInstance($instance))->toBeFalse();
}
});
it('zeigt gesendete Abo-Zahlungslinks in der Kunden-Abo-Uebersicht', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters));
$fixture = createPendingInitialAboFixture();
$this->actingAs($fixture['user']);
$paymentLink = new ShoppingInstance([
'identifier' => 'paylink-'.uniqid(),
'user_shop_id' => $fixture['userShop']->id,
'payment' => 6,
'subdomain' => 'test.local',
'country_id' => $fixture['countryId'],
'amount' => 99.50,
'shopping_user_id' => $fixture['shoppingUser']->id,
'status' => 0,
'shopping_data' => [
'is_for' => 'abo-ot-customer',
'is_abo' => true,
'billing_email' => $fixture['shoppingUser']->billing_email,
],
]);
$paymentLink->created_at = now();
$html = view('user.abo.index', [
'user_abos' => collect(),
'abo_payment_links' => collect([$paymentLink]),
'view' => 'ot',
'isAdmin' => false,
])->render();
expect($html)
->toContain(__('abo.payment_link_sent_title'))
->toContain($paymentLink->identifier)
->toContain($fixture['shoppingUser']->billing_email)
->toContain(__('delete'));
});
it('blendet Abo-Zahlungslinks aus sobald daraus ein Abo existiert', function (): void {
$fixture = createPendingInitialAboFixture(['is_for' => 'ot']);
$paymentLink = new ShoppingInstance([
'user_shop_id' => $fixture['userShop']->id,
'payment' => 6,
'shopping_user_id' => $fixture['shoppingUser']->id,
'status' => 3,
'shopping_data' => [
'is_for' => 'abo-ot-customer',
'is_abo' => true,
'shopping_user_id' => $fixture['shoppingUser']->id,
'billing_email' => $fixture['shoppingUser']->billing_email,
],
]);
expect(OrderPaymentService::hasUserAboForPaymentLink($paymentLink, $fixture['user']))->toBeTrue();
});
/**
* @return array{user: User, userShop: UserShop, shoppingUser: ShoppingUser, userAbo: UserAbo, payment: ShoppingPayment, countryId: int}
*/
function createPendingInitialAboFixture(array $aboOverrides = [], array $paymentOverrides = []): array
{
$country = Country::create([
'code' => 'DE',
'phone' => '49',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Deutschland',
]);
$shipping = Shipping::create([
'name' => 'Standard',
'active' => true,
]);
$shippingCountry = ShippingCountry::create([
'shipping_id' => $shipping->id,
'country_id' => $country->id,
]);
$user = User::forceCreate([
'email' => 'pending-abo-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$userShop = UserShop::create([
'user_id' => $user->id,
'name' => 'TS'.substr(uniqid('', true), 0, 8),
'slug' => 'ts-'.uniqid(),
'active' => true,
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'member_id' => $user->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => 'pending-customer-'.uniqid('', true).'@example.com',
'shipping_email' => $user->email,
'is_for' => $aboOverrides['is_for'] ?? 'me',
'is_from' => 'user_order',
]);
$shoppingOrder = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $user->id,
'member_id' => $user->id,
'country_id' => $shippingCountry->id,
'user_shop_id' => $userShop->id,
'payment_for' => 2,
'is_abo' => true,
'abo_interval' => 5,
'paid' => false,
'mode' => 'test',
'total' => 100,
'subtotal' => 90,
]);
$payment = ShoppingPayment::create(array_merge([
'shopping_order_id' => $shoppingOrder->id,
'reference' => 'pending-ref-'.uniqid(),
'amount' => 10000,
'currency' => 'EUR',
'clearingtype' => 'cc',
'abo_interval' => 5,
'mode' => 'test',
], $paymentOverrides));
if (array_key_exists('txaction', $paymentOverrides)) {
$payment->txaction = $paymentOverrides['txaction'];
$payment->save();
}
$userAbo = UserAbo::create(array_merge([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => $shoppingUser->id,
'email' => $shoppingUser->billing_email,
'is_for' => $aboOverrides['is_for'] ?? 'me',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 10000,
'status' => 1,
'abo_interval' => 5,
'start_date' => now(),
'last_date' => now(),
'next_date' => now()->addMonth(),
], $aboOverrides));
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $shoppingOrder->id,
'status' => 1,
'paid' => false,
]);
return [
'user' => $user,
'userShop' => $userShop,
'shoppingUser' => $shoppingUser,
'userAbo' => $userAbo,
'payment' => $payment,
'countryId' => $shippingCountry->id,
];
}