+ @include('admin.abo._retry_payment_modal', ['retryAction' => route('portal_abos_retry_payment', [$user_abo->id])])
diff --git a/resources/views/user/abo/detail.blade.php b/resources/views/user/abo/detail.blade.php
index 29b6d82..b757c0b 100644
--- a/resources/views/user/abo/detail.blade.php
+++ b/resources/views/user/abo/detail.blade.php
@@ -16,6 +16,15 @@
+ @if (Session::has('alert-success'))
+
@@ -107,6 +116,7 @@
@include('admin.abo._executions')
+ @include('admin.abo._retry_payment_modal', ['retryAction' => route('user_abos_retry_payment', [$view, $user_abo->id])])
{{ __('back') }}
diff --git a/routes/domains/crm.php b/routes/domains/crm.php
index f0cc88e..c082e36 100644
--- a/routes/domains/crm.php
+++ b/routes/domains/crm.php
@@ -161,6 +161,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/user/abos/detail/{view}/{id}', 'User\AboController@detail')->name('user_abos_detail');
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::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)
diff --git a/routes/domains/portal.php b/routes/domains/portal.php
index cdcf28e..c641794 100644
--- a/routes/domains/portal.php
+++ b/routes/domains/portal.php
@@ -49,6 +49,7 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_
Route::match(['get', 'post'], 'portal/my-subscriptions/create/{step}', [AboController::class, 'myAboCreate'])->name('portal.my_subscriptions.create');
Route::post('portal/my-subscriptions/update/{view}/{id}', [AboController::class, 'update'])->name('user_abos_update');
Route::post('portal/my-subscriptions/onetime/{view}/{id}', [AboController::class, 'oneTime'])->name('user_abos_onetime');
+ Route::post('portal/my-subscriptions/retry-payment/{id}', [AboController::class, 'retryPayment'])->name('portal_abos_retry_payment');
Route::get('portal/my-subscriptions/datatable/{id}', [AboController::class, 'datatable'])->name('user_abo_datatable');
Route::get('portal/my-subscriptions/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable');
Route::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load');
diff --git a/tests/Feature/AboMakeOrderOneTimeTest.php b/tests/Feature/AboMakeOrderOneTimeTest.php
new file mode 100644
index 0000000..5309b8e
--- /dev/null
+++ b/tests/Feature/AboMakeOrderOneTimeTest.php
@@ -0,0 +1,269 @@
+ 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
+ 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
+ ]);
+
+ $shipping = Shipping::create(['name' => 'Standard', 'active' => true]);
+ ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
+
+ $account = UserAccount::create([
+ 'salutation' => 'Herr',
+ 'first_name' => 'Max',
+ 'last_name' => 'Muster',
+ 'address' => 'Musterstr. 1',
+ 'zipcode' => '12345',
+ 'city' => 'Musterstadt',
+ 'country_id' => $country->id,
+ 'phone' => '123456',
+ 'same_as_billing' => 1,
+ 'language' => 'de',
+ ]);
+
+ $user = User::forceCreate([
+ 'email' => 'consultant-'.uniqid('', true).'@example.com',
+ 'password' => bcrypt('secret'),
+ 'lang' => 'de',
+ 'account_id' => $account->id,
+ ]);
+
+ $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' => $user->email,
+ 'shipping_email' => $user->email,
+ 'billing_firstname' => 'Max',
+ 'billing_lastname' => 'Muster',
+ 'shipping_firstname' => 'Max',
+ 'shipping_lastname' => 'Muster',
+ 'same_as_billing' => 1,
+ 'is_for' => 'me',
+ 'is_from' => 'user_order',
+ ]);
+
+ ShoppingOrder::create([
+ 'shopping_user_id' => $shoppingUser->id,
+ 'auth_user_id' => $user->id,
+ 'member_id' => $user->id,
+ 'country_id' => $country->id,
+ 'user_shop_id' => $userShop->id,
+ 'payment_for' => 3,
+ 'total' => 100,
+ 'subtotal' => 90,
+ 'total_shipping' => 100,
+ 'paid' => true,
+ 'is_abo' => true,
+ 'txaction' => 'paid',
+ 'mode' => 'test',
+ ]);
+
+ $abo = UserAbo::create([
+ 'user_id' => $user->id,
+ 'member_id' => $user->id,
+ 'shopping_user_id' => $shoppingUser->id,
+ 'is_for' => 'me',
+ 'email' => $user->email,
+ 'payone_userid' => 900200,
+ 'clearingtype' => 'cc',
+ 'active' => true,
+ 'status' => 2,
+ 'abo_interval' => 5,
+ 'next_date' => now()->toDateString(),
+ ]);
+
+ $aboProduct = makeProduct(['12'], 119, 19);
+ $oneTimeProduct = makeProduct(['2'], 50, 19);
+
+ UserAboItem::create([
+ 'user_abo_id' => $abo->id,
+ 'product_id' => $aboProduct->id,
+ 'qty' => 1,
+ 'comp' => 0,
+ 'status' => 1,
+ ]);
+
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id,
+ 'product_id' => $oneTimeProduct->id,
+ 'qty' => 2,
+ 'confirmed_qty' => 2,
+ 'confirmed_at' => now(),
+ 'price' => 50.0,
+ 'price_net' => 42.017,
+ 'tax_rate' => 19.0,
+ 'tax' => 7.983,
+ 'points' => 7,
+ 'status' => 1,
+ ]);
+
+ return [
+ 'abo' => $abo->fresh(),
+ 'aboProductId' => $aboProduct->id,
+ 'oneTimeProductId' => $oneTimeProduct->id,
+ ];
+}
+
+it('schreibt verbindlich bestätigte Einmal-Artikel mit is_abo_addon in die Bestellung', function () {
+ $fixture = makeMakeOrderFixture();
+
+ $userMakeOrder = new UserMakeOrder($fixture['abo']);
+ $userMakeOrder->createShoppingUser();
+ $order = $userMakeOrder->makeShoppingOrder();
+
+ expect($order)->not->toBeFalse();
+
+ $aboItem = $order->shopping_order_items()
+ ->where('product_id', $fixture['aboProductId'])
+ ->first();
+ $addonItem = $order->shopping_order_items()
+ ->where('product_id', $fixture['oneTimeProductId'])
+ ->first();
+
+ expect($aboItem)->not->toBeNull()
+ ->and((bool) $aboItem->is_abo_addon)->toBeFalse()
+ ->and($addonItem)->not->toBeNull()
+ ->and((bool) $addonItem->is_abo_addon)->toBeTrue()
+ ->and((int) $addonItem->qty)->toBe(2);
+});
+
+it('lässt user_abos.amount der reine Abo-Betrag (ohne Einmal-Artikel)', function () {
+ $fixture = makeMakeOrderFixture();
+
+ $userMakeOrder = new UserMakeOrder($fixture['abo']);
+ $userMakeOrder->createShoppingUser();
+ $order = $userMakeOrder->makeShoppingOrder();
+
+ $pureAboAmount = (float) $fixture['abo']->fresh()->amount; // Cent, nur Abo
+ $combinedTotal = (float) $order->total_shipping * 100; // Cent, inkl. Einmal-Artikel
+
+ // Der kombinierte Abbuchungsbetrag enthält die Einmal-Artikel, der gespeicherte
+ // Abo-Betrag jedoch nicht -> kombiniert ist größer.
+ expect($combinedTotal)->toBeGreaterThan($pureAboAmount);
+});
+
+it('nimmt nur bestätigte Einmal-Artikel auf, offene Entwürfe nicht', function () {
+ $fixture = makeMakeOrderFixture();
+ $abo = $fixture['abo'];
+
+ // Zusätzlicher, NICHT bestätigter Einmal-Artikel
+ $pendingProduct = makeProduct(['2'], 30, 19);
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id,
+ 'product_id' => $pendingProduct->id,
+ 'qty' => 1,
+ 'price' => 30.0,
+ 'tax_rate' => 19.0,
+ 'status' => 0,
+ ]);
+
+ $userMakeOrder = new UserMakeOrder($abo);
+ $userMakeOrder->createShoppingUser();
+ $order = $userMakeOrder->makeShoppingOrder();
+
+ expect($order->shopping_order_items()->where('product_id', $pendingProduct->id)->exists())->toBeFalse()
+ ->and($order->shopping_order_items()->where('product_id', $fixture['oneTimeProductId'])->exists())->toBeTrue();
+});
+
+it('entfernt beim Purge ALLE Einmal-Artikel (bestätigt, offen und soft-deleted)', function () {
+ $fixture = makeMakeOrderFixture();
+ $abo = $fixture['abo'];
+
+ // zusätzlich ein offener und ein soft-deleted Artikel
+ $pending = makeProduct(['2'], 30, 19);
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id,
+ 'product_id' => $pending->id,
+ 'qty' => 1,
+ 'price' => 30.0,
+ 'tax_rate' => 19.0,
+ 'status' => 0,
+ ]);
+ $trashed = makeProduct(['2'], 20, 19);
+ $trashedItem = UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id,
+ 'product_id' => $trashed->id,
+ 'qty' => 1,
+ 'confirmed_qty' => 1,
+ 'confirmed_at' => now(),
+ 'price' => 20.0,
+ 'tax_rate' => 19.0,
+ 'status' => 1,
+ ]);
+ $trashedItem->delete();
+
+ expect(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(3);
+
+ AboOneTimeService::purgeAfterExecution($abo);
+
+ expect(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(0);
+});
+
+it('reduziert nach dem Purge nicht mehr benötigte Kompensationsprodukte', function () {
+ $fixture = makeMakeOrderFixture();
+ $abo = $fixture['abo'];
+
+ // Ein Comp-Abo-Artikel, der nach Wegfall der Einmal-Artikel nicht mehr nötig ist.
+ $compProduct = makeProduct(['12'], 0, 0);
+ UserAboItem::create([
+ 'user_abo_id' => $abo->id,
+ 'product_id' => $compProduct->id,
+ 'comp' => 1,
+ 'qty' => 1,
+ 'status' => 1,
+ ]);
+
+ expect(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->count())->toBe(1);
+
+ AboOneTimeService::purgeAfterExecution($abo);
+
+ expect(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->count())->toBe(0)
+ ->and(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(0);
+});
+
+it('entfernt durch reine Bestellerstellung KEINE Einmal-Artikel (behalten bei Fehler)', function () {
+ $fixture = makeMakeOrderFixture();
+ $abo = $fixture['abo'];
+
+ $userMakeOrder = new UserMakeOrder($abo);
+ $userMakeOrder->createShoppingUser();
+ $userMakeOrder->makeShoppingOrder();
+
+ // makeShoppingOrder läuft sowohl im Erfolgs- als auch im Fehlerfall; der Purge
+ // erfolgt ausschließlich separat im Erfolgszweig. Daher bleiben die Artikel hier erhalten.
+ expect(UserAboOneTimeItem::where('user_abo_id', $abo->id)->count())->toBe(1);
+});
diff --git a/tests/Feature/AboOneTimeServiceTest.php b/tests/Feature/AboOneTimeServiceTest.php
index 1325120..7a27841 100644
--- a/tests/Feature/AboOneTimeServiceTest.php
+++ b/tests/Feature/AboOneTimeServiceTest.php
@@ -129,6 +129,71 @@ describe('AboOneTimeService', function () {
expect($this->service->handleAction($abo, ['action' => 'foo']))->toBe(__('abo.onetime_action_invalid'));
});
+
+ it('trennt bestätigte und offene Einmal-Artikel inkl. offener Zwischensumme', function () {
+ $abo = makeMeAbo();
+ $confirmed = makeProduct(['2'], 119, 19);
+ $pending = makeProduct(['2'], 50, 19);
+
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id, 'product_id' => $confirmed->id,
+ 'qty' => 1, 'confirmed_qty' => 1, 'confirmed_at' => now(),
+ 'price' => 119.0, 'tax_rate' => 19.0, 'status' => 1,
+ ]);
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id, 'product_id' => $pending->id,
+ 'qty' => 2, 'price' => 50.0, 'tax_rate' => 19.0, 'status' => 0,
+ ]);
+
+ expect(AboOneTimeService::confirmedItems($abo)->count())->toBe(1)
+ ->and(AboOneTimeService::pendingItems($abo)->count())->toBe(1)
+ ->and(AboOneTimeService::pendingGross($abo))->toBe(100.0);
+ });
+
+ it('behandelt einen geänderten bestätigten Artikel als offen', function () {
+ $abo = makeMeAbo();
+ $product = makeProduct(['2'], 119, 19);
+
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id, 'product_id' => $product->id,
+ 'qty' => 3, 'confirmed_qty' => 1, 'confirmed_at' => now(),
+ 'price' => 119.0, 'tax_rate' => 19.0, 'status' => 0,
+ ]);
+
+ expect(AboOneTimeService::confirmedItems($abo)->count())->toBe(0)
+ ->and(AboOneTimeService::pendingItems($abo)->count())->toBe(1)
+ ->and(AboOneTimeService::pendingGross($abo))->toBe(357.0);
+ });
+});
+
+describe('AboOrderCart::addOneTimeItemsToYard', function () {
+ beforeEach(fn () => makeShopEnv());
+
+ it('lädt nur verbindlich bestätigte Einmal-Artikel in den Warenkorb', function () {
+ $abo = makeMeAbo();
+ $confirmed = makeProduct(['2'], 119, 19);
+ $pending = makeProduct(['2'], 50, 19);
+
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id, 'product_id' => $confirmed->id,
+ 'qty' => 1, 'confirmed_qty' => 1, 'confirmed_at' => now(),
+ 'price' => 119.0, 'tax_rate' => 19.0, 'status' => 1,
+ ]);
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id, 'product_id' => $pending->id,
+ 'qty' => 1, 'price' => 50.0, 'tax_rate' => 19.0, 'status' => 0,
+ ]);
+
+ $yard = Yard::instance(AboOrderCart::INSTANCE);
+ $yard->destroy();
+
+ AboOrderCart::addOneTimeItemsToYard($abo->fresh());
+
+ expect($yard->content()->count())->toBe(1)
+ ->and((int) $yard->content()->first()->id)->toBe($confirmed->id);
+
+ $yard->destroy();
+ });
});
describe('AboOrderCart::buildOneTimeSnapshot', function () {
diff --git a/tests/Feature/AboOneTimeViewTest.php b/tests/Feature/AboOneTimeViewTest.php
index bdcd4ff..e7a3d0a 100644
--- a/tests/Feature/AboOneTimeViewTest.php
+++ b/tests/Feature/AboOneTimeViewTest.php
@@ -9,7 +9,7 @@ uses(Tests\TestCase::class, RefreshDatabase::class);
describe('Abo Einmal-Produkte Views', function () {
beforeEach(fn () => makeShopEnv());
- it('rendert die Einmal-Produktliste mit Position und Zwischensumme', function () {
+ it('rendert die Einmal-Produktliste mit Position und offener Zwischensumme', function () {
$abo = makeMeAbo();
$product = makeProduct(['2'], 119, 19);
@@ -21,7 +21,7 @@ describe('Abo Einmal-Produkte Views', function () {
'price_net' => 100.0,
'tax_rate' => 19.0,
'tax' => 19.0,
- 'status' => 1,
+ 'status' => 0,
]);
$summary = ['one_time' => ['gross' => 238.0, 'net' => 200.0, 'tax' => 38.0]];
@@ -32,10 +32,50 @@ describe('Abo Einmal-Produkte Views', function () {
])->render();
expect($html)->toContain($product->getLang('name'))
- ->and($html)->toContain(__('abo.onetime_subtotal'))
+ ->and($html)->toContain(__('abo.onetime_pending_subtotal'))
+ ->and($html)->toContain(__('abo.onetime_status_pending'))
->and($html)->toContain('238,00');
});
+ it('zeigt bestätigte und offene Artikel mit getrennten Zwischensummen', function () {
+ $abo = makeMeAbo();
+ $confirmed = makeProduct(['2'], 119, 19);
+ $pending = makeProduct(['2'], 50, 19);
+
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id,
+ 'product_id' => $confirmed->id,
+ 'qty' => 1,
+ 'confirmed_qty' => 1,
+ 'confirmed_at' => now(),
+ 'price' => 119.0,
+ 'tax_rate' => 19.0,
+ 'status' => 1,
+ ]);
+ UserAboOneTimeItem::create([
+ 'user_abo_id' => $abo->id,
+ 'product_id' => $pending->id,
+ 'qty' => 1,
+ 'price' => 50.0,
+ 'tax_rate' => 19.0,
+ 'status' => 0,
+ ]);
+
+ $html = view('admin.abo._order_onetime_show', [
+ 'user_abo' => $abo->fresh(),
+ 'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0],
+ ])->render();
+
+ expect($html)->toContain(__('abo.onetime_confirmed_subtotal'))
+ ->and($html)->toContain(__('abo.onetime_pending_subtotal'))
+ ->and($html)->toContain(__('abo.onetime_new_total'))
+ ->and($html)->toContain(__('abo.onetime_status_confirmed'))
+ ->and($html)->toContain(__('abo.onetime_status_pending'))
+ ->and($html)->toContain(__('abo.onetime_pending_hint'))
+ ->and($html)->toContain('50,00')
+ ->and($html)->toContain('169,00');
+ });
+
it('zeigt bei unbestätigten Einmal-Artikeln die Bestätigungsbuttons', function () {
$abo = makeMeAbo();
$product = makeProduct(['2'], 119, 19);
@@ -56,7 +96,7 @@ describe('Abo Einmal-Produkte Views', function () {
'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0],
])->render();
- expect($html)->toContain(__('abo.onetime_next_delivery_total'))
+ expect($html)->toContain(__('abo.onetime_pending_subtotal'))
->and($html)->toContain(__('abo.onetime_legal_notice', [
'nextBillingDate' => $abo->next_date,
'agb' => '
'.__('abo.confirm_terms_link').'',
diff --git a/tests/Feature/AboOneTimeWindowTest.php b/tests/Feature/AboOneTimeWindowTest.php
index 11517dc..b1bacc2 100644
--- a/tests/Feature/AboOneTimeWindowTest.php
+++ b/tests/Feature/AboOneTimeWindowTest.php
@@ -5,6 +5,7 @@ use App\Models\ShoppingOrderItem;
use App\Models\UserAbo;
use App\Models\UserAboOneTimeItem;
use App\Services\AboHelper;
+use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -82,6 +83,48 @@ describe('isOneTimeWindowOpen', function () {
});
});
+describe('isOneTimeFeatureVisible', function () {
+ it('ist sichtbar, wenn das Fenster offen ist und ein VIP-User (Admin) eingeloggt ist', function () {
+ $vip = new User;
+ $vip->admin = 1;
+ $this->actingAs($vip, 'user');
+
+ $abo = new UserAbo;
+ $abo->next_date = now()->addDays(2); // innerhalb des 4-Tage-Fensters
+
+ expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue();
+ });
+
+ it('ist nicht sichtbar für eingeloggte Nicht-VIP-User', function () {
+ $user = new User;
+ $user->admin = 0;
+ $this->actingAs($user, 'user');
+
+ $abo = new UserAbo;
+ $abo->next_date = now()->addDays(2);
+
+ expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
+ });
+
+ it('ist nicht sichtbar ohne User auf dem user-Guard (Portal/Endkunde)', function () {
+ $abo = new UserAbo;
+ $abo->next_date = now()->addDays(2);
+
+ expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
+ });
+
+ it('ist nicht sichtbar bei geschlossenem Fenster, auch für VIP-User', function () {
+ $vip = new User;
+ $vip->admin = 1;
+ $this->actingAs($vip, 'user');
+
+ $abo = new UserAbo;
+ $abo->next_date = now()->addDays(30); // außerhalb des Fensters
+
+ expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
+ });
+});
+
describe('UserAboOneTimeItem Model', function () {
it('definiert die Beziehung one_time_items auf UserAbo', function () {
$relation = (new UserAbo)->one_time_items();
diff --git a/tests/Feature/AboUserRetryTest.php b/tests/Feature/AboUserRetryTest.php
new file mode 100644
index 0000000..22caec5
--- /dev/null
+++ b/tests/Feature/AboUserRetryTest.php
@@ -0,0 +1,78 @@
+ 'owner-'.uniqid('', true).'@example.com',
+ 'password' => bcrypt('secret'),
+ 'lang' => 'de',
+ ]);
+
+ $abo = UserAbo::create([
+ 'user_id' => $owner->id,
+ 'member_id' => $owner->id,
+ 'shopping_user_id' => 1,
+ 'is_for' => 'me',
+ 'email' => $owner->email,
+ 'payone_userid' => 900300,
+ 'clearingtype' => 'cc',
+ 'active' => true,
+ 'status' => 3,
+ 'abo_interval' => 5,
+ 'next_date' => now()->toDateString(),
+ ]);
+
+ return ['owner' => $owner, 'abo' => $abo];
+}
+
+it('führt den User-Retry für den eigenen Abo aus und leitet zur Detailseite zurück', function () {
+ // Domain-Routen werden hostabhängig geladen; Stub für den Redirect der Methode.
+ Route::get('/user/abos/detail/{view}/{id}', fn () => '')->name('user_abos_detail');
+ app('router')->getRoutes()->refreshNameLookups();
+
+ $fixture = makeRetryOwnerAbo();
+ $this->actingAs($fixture['owner']);
+
+ mock(AboRetryPaymentService::class)
+ ->shouldReceive('retry')
+ ->once()
+ ->andReturn(['success' => true, 'message' => 'OK']);
+
+ $controller = new AboController(app(AboRepository::class));
+ $response = $controller->retryPayment('me', $fixture['abo']->id, app(AboRetryPaymentService::class));
+
+ expect($response->getTargetUrl())->toContain('/user/abos/detail/me/'.$fixture['abo']->id);
+ expect(session('alert-success'))->toBe('OK');
+});
+
+it('verhindert den User-Retry für ein fremdes Abo (403)', function () {
+ $fixture = makeRetryOwnerAbo();
+
+ $stranger = User::forceCreate([
+ 'email' => 'stranger-'.uniqid('', true).'@example.com',
+ 'password' => bcrypt('secret'),
+ 'lang' => 'de',
+ ]);
+ $this->actingAs($stranger);
+
+ $service = mock(AboRetryPaymentService::class);
+ $service->shouldNotReceive('retry');
+
+ $controller = new AboController(app(AboRepository::class));
+
+ expect(fn () => $controller->retryPayment('me', $fixture['abo']->id, $service))
+ ->toThrow(HttpException::class);
+});