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:
parent
78db952130
commit
0660f4e0a0
28 changed files with 1535 additions and 181 deletions
42
tests/Feature/AboPaymentFailedMailTest.php
Normal file
42
tests/Feature/AboPaymentFailedMailTest.php
Normal 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');
|
||||
});
|
||||
113
tests/Feature/AboRecoveryAdvanceTest.php
Normal file
113
tests/Feature/AboRecoveryAdvanceTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue