Abo-Recovery: fehlgeschlagene Folgezahlung im Checkout nachholen

- Pay-now-Flow: bei fehlgeschlagenem Abo (Status 3) begleicht der Inhaber die
  offene Rechnung im Standard-Checkout mit frei waehlbarer, abo-faehiger Zahlart
  (nur eigenes Abo: Berater me-View + Kundenportal), payNow-Controller + Routen
- AboRecoveryCheckoutService baut die ShoppingInstance aus den Abo-Artikeln;
  neue Spalte shopping_orders.recovery_user_abo_id verknuepft Bestellung<->Abo
- Bei Erfolg: AboHelper::advanceAboAfterRecoveryPayment schreibt das Abo fort
  (Status->2, next_date) und uebernimmt die gewaehlte Zahlart als neues Token
  fuer kuenftige Abbuchungen
- Neue Pay-now-Fehlermail (AboPaymentFailedMail) ersetzt die generische
  Fehlermail im Abo-Cron; Link fuehrt auf die Abo-Detailseite (CRM bzw. Portal)
- Retry-/"extern erledigt"-Buttons nur noch fuer Admins; tote user/portal-
  Retry-Routen entfernt
- Checkout: Recovery-Hinweis statt "Abschluss des Abonnements", Lieferadresse
  vorbelegt, korrekter Zurueck-Link (Abo statt Shop)
- UserAbo::selfServiceDetailUrl zentralisiert die Abo-URL (Mail, Back-Links)
- Test-Command abo:test-recovery; Tests fuer payNow, Advance-Logik, Fehlermail

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-02 10:38:05 +00:00
parent 78db952130
commit 0660f4e0a0
28 changed files with 1535 additions and 181 deletions

View file

@ -0,0 +1,42 @@
<?php
use App\Mail\AboPaymentFailedMail;
use App\Models\UserAbo;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
function makeFailedAbo(string $isFor): UserAbo
{
return UserAbo::create([
'user_id' => 1,
'member_id' => 1,
'shopping_user_id' => 1,
'is_for' => $isFor,
'email' => 'owner-'.uniqid('', true).'@example.com',
'payone_userid' => 500100,
'clearingtype' => 'cc',
'active' => true,
'status' => 3,
'abo_interval' => 5,
'next_date' => now()->toDateString(),
]);
}
it('verlinkt bei einem Berater-Abo (me) auf die CRM-Detailseite', function () {
$abo = makeFailedAbo('me');
$mail = (new AboPaymentFailedMail($abo))->build();
expect($mail->viewData['link'])->toContain('/user/abos/detail/me/'.$abo->id);
expect($mail->subject)->toBe(__('abo.payment_failed_mail_subject'));
});
it('verlinkt bei einem Kunden-Abo (ot) auf das Kundenportal', function () {
$abo = makeFailedAbo('ot');
$mail = (new AboPaymentFailedMail($abo))->build();
expect($mail->viewData['link'])->toContain('/portal/my-subscriptions');
expect($mail->viewData['link'])->not->toContain('/user/abos/detail');
});

View file

@ -0,0 +1,113 @@
<?php
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\AboHelper;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
function makeRecoveryAbo(): UserAbo
{
return UserAbo::create([
'user_id' => 1,
'member_id' => 1,
'shopping_user_id' => 1,
'auth_user_id' => 1,
'is_for' => 'me',
'email' => 'recovery-'.uniqid('', true).'@example.com',
'payone_userid' => 770100,
'clearingtype' => 'cc',
'active' => true,
'status' => 3,
'abo_interval' => 5,
'next_date' => now()->subDays(3)->toDateString(),
'last_date' => now()->subMonth()->toDateString(),
]);
}
function makeRecoveryPayment(UserAbo $abo): ShoppingPayment
{
$order = ShoppingOrder::create([
'shopping_user_id' => 1,
'auth_user_id' => 1,
'country_id' => 1,
'user_shop_id' => 1,
'recovery_user_abo_id' => $abo->id,
'is_abo' => true,
'abo_interval' => 5,
'paid' => false,
]);
return ShoppingPayment::create([
'shopping_order_id' => $order->id,
'reference' => uniqid('ref', true),
'clearingtype' => 'cc',
'amount' => 19.9,
'currency' => 'EUR',
'mode' => 'test',
'is_abo' => true,
'abo_interval' => 5,
]);
}
it('schreibt das bestehende Abo bei erfolgreicher Recovery-Zahlung fort, ohne ein neues anzulegen', function () {
$abo = makeRecoveryAbo();
$payment = makeRecoveryPayment($abo);
AboHelper::advanceAboAfterRecoveryPayment($payment);
$abo->refresh();
expect($abo->status)->toBe(2);
expect((bool) $abo->active)->toBeTrue();
expect(\Carbon\Carbon::parse($abo->next_date)->isFuture())->toBeTrue();
expect(UserAbo::count())->toBe(1);
expect(
UserAboOrder::where('shopping_order_id', $payment->shopping_order_id)
->where('user_abo_id', $abo->id)
->where('paid', true)
->exists()
)->toBeTrue();
});
it('ist idempotent und legt bei erneutem Aufruf keinen zweiten UserAboOrder an', function () {
$abo = makeRecoveryAbo();
$payment = makeRecoveryPayment($abo);
AboHelper::advanceAboAfterRecoveryPayment($payment);
AboHelper::advanceAboAfterRecoveryPayment($payment);
expect(UserAboOrder::where('shopping_order_id', $payment->shopping_order_id)->count())->toBe(1);
});
it('ignoriert Zahlungen ohne recovery_user_abo_id', function () {
$abo = makeRecoveryAbo();
$order = ShoppingOrder::create([
'shopping_user_id' => 1,
'auth_user_id' => 1,
'country_id' => 1,
'user_shop_id' => 1,
'is_abo' => true,
'abo_interval' => 5,
'paid' => false,
]);
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'reference' => uniqid('ref', true),
'clearingtype' => 'cc',
'amount' => 19.9,
'currency' => 'EUR',
'mode' => 'test',
'is_abo' => true,
'abo_interval' => 5,
]);
AboHelper::advanceAboAfterRecoveryPayment($payment);
$abo->refresh();
expect($abo->status)->toBe(3);
expect(UserAboOrder::count())->toBe(0);
});

View file

@ -3,7 +3,7 @@
use App\Http\Controllers\User\AboController;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
use App\Services\AboRetryPaymentService;
use App\Services\AboRecoveryCheckoutService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
@ -13,7 +13,7 @@ use function Pest\Laravel\mock;
uses(Tests\TestCase::class, RefreshDatabase::class);
function makeRetryOwnerAbo(): array
function makeRetryOwnerAbo(int $status = 3, bool $active = true): array
{
$owner = User::forceCreate([
'email' => 'owner-'.uniqid('', true).'@example.com',
@ -29,8 +29,8 @@ function makeRetryOwnerAbo(): array
'email' => $owner->email,
'payone_userid' => 900300,
'clearingtype' => 'cc',
'active' => true,
'status' => 3,
'active' => $active,
'status' => $status,
'abo_interval' => 5,
'next_date' => now()->toDateString(),
]);
@ -38,27 +38,22 @@ function makeRetryOwnerAbo(): array
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();
it('startet den Recovery-Checkout für das eigene fehlgeschlagene Abo und leitet dorthin weiter', function () {
$fixture = makeRetryOwnerAbo();
$this->actingAs($fixture['owner']);
mock(AboRetryPaymentService::class)
->shouldReceive('retry')
mock(AboRecoveryCheckoutService::class)
->shouldReceive('buildCheckoutUrl')
->once()
->andReturn(['success' => true, 'message' => 'OK']);
->andReturn('https://checkout.test/checkout/card/abc123');
$controller = new AboController(app(AboRepository::class));
$response = $controller->retryPayment('me', $fixture['abo']->id, app(AboRetryPaymentService::class));
$response = $controller->payNow('me', $fixture['abo']->id, app(AboRecoveryCheckoutService::class));
expect($response->getTargetUrl())->toContain('/user/abos/detail/me/'.$fixture['abo']->id);
expect(session('alert-success'))->toBe('OK');
expect($response->getTargetUrl())->toBe('https://checkout.test/checkout/card/abc123');
});
it('verhindert den User-Retry für ein fremdes Abo (403)', function () {
it('verhindert die nachträgliche Zahlung für ein fremdes Abo (403)', function () {
$fixture = makeRetryOwnerAbo();
$stranger = User::forceCreate([
@ -68,11 +63,28 @@ it('verhindert den User-Retry für ein fremdes Abo (403)', function () {
]);
$this->actingAs($stranger);
$service = mock(AboRetryPaymentService::class);
$service->shouldNotReceive('retry');
$service = mock(AboRecoveryCheckoutService::class);
$service->shouldNotReceive('buildCheckoutUrl');
$controller = new AboController(app(AboRepository::class));
expect(fn () => $controller->retryPayment('me', $fixture['abo']->id, $service))
expect(fn () => $controller->payNow('me', $fixture['abo']->id, $service))
->toThrow(HttpException::class);
});
it('lehnt die nachträgliche Zahlung ab, wenn das Abo nicht fehlgeschlagen ist', function () {
Route::post('/user/abos/detail/{view}/{id}', fn () => '')->name('user_abos_detail');
app('router')->getRoutes()->refreshNameLookups();
$fixture = makeRetryOwnerAbo(status: 2);
$this->actingAs($fixture['owner']);
$service = mock(AboRecoveryCheckoutService::class);
$service->shouldNotReceive('buildCheckoutUrl');
$controller = new AboController(app(AboRepository::class));
$response = $controller->payNow('me', $fixture['abo']->id, $service);
expect($response->getTargetUrl())->toContain('/user/abos/detail/me/'.$fixture['abo']->id);
expect(session('alert-error'))->toBe(__('abo.pay_now_not_allowed'));
});