Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert
Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
6.4 KiB
PHP
223 lines
6.4 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\Portal\AboController as PortalAboController;
|
|
use App\Http\Controllers\User\AboController;
|
|
use App\Models\Customer;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboOrder;
|
|
use App\Repositories\AboRepository;
|
|
use App\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow(Carbon::parse('2026-07-22'));
|
|
URL::resolveMissingNamedRoutesUsing(
|
|
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
|
|
);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
function auaMakeUser(): User
|
|
{
|
|
return User::forceCreate([
|
|
'email' => 'aua-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'm_level' => 1,
|
|
'payment_account' => now()->addYear(),
|
|
]);
|
|
}
|
|
|
|
function auaMakeAbo(User $user, int $paidDeliveries = 6, string $nextDate = '2026-08-05', ?string $lastPauseDate = null): UserAbo
|
|
{
|
|
$abo = UserAbo::create([
|
|
'user_id' => $user->id,
|
|
'member_id' => $user->id,
|
|
'shopping_user_id' => 0,
|
|
'email' => 'aua-abo-'.uniqid('', true).'@example.com',
|
|
'is_for' => 'me',
|
|
'payone_userid' => 0,
|
|
'clearingtype' => 'cc',
|
|
'amount' => 5000,
|
|
'active' => true,
|
|
'status' => 2,
|
|
'abo_interval' => 5,
|
|
'start_date' => '2026-01-05',
|
|
'last_date' => '2026-07-05',
|
|
'next_date' => $nextDate,
|
|
'last_pause_date' => $lastPauseDate,
|
|
]);
|
|
|
|
for ($i = 0; $i < $paidDeliveries; $i++) {
|
|
UserAboOrder::create([
|
|
'user_abo_id' => $abo->id,
|
|
'shopping_order_id' => 0,
|
|
'status' => 2,
|
|
'paid' => true,
|
|
]);
|
|
}
|
|
|
|
return $abo->fresh();
|
|
}
|
|
|
|
/**
|
|
* Simuliert den POST des Abo-Einstellungs-Modals an AboController@update.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
function auaCallUpdate(string $routeView, int $routeId, array $payload): mixed
|
|
{
|
|
app('request')->merge($payload);
|
|
|
|
return (new AboController(app(AboRepository::class)))->update($routeView, $routeId);
|
|
}
|
|
|
|
it('blockiert das Kündigen eines fremden Abos über eine manipulierte Body-id', function (): void {
|
|
$attacker = auaMakeUser();
|
|
$victim = auaMakeUser();
|
|
$attackerAbo = auaMakeAbo($attacker);
|
|
$victimAbo = auaMakeAbo($victim);
|
|
|
|
$this->actingAs($attacker);
|
|
|
|
expect(fn () => auaCallUpdate('me', $attackerAbo->id, [
|
|
'action' => 'abo_update_settings',
|
|
'id' => $victimAbo->id,
|
|
'view' => 'admin',
|
|
'abo_interval' => 5,
|
|
'abo_next_month' => now()->addMonth()->format('Y-m'),
|
|
'abo_cancel' => 'true',
|
|
]))->toThrow(HttpException::class);
|
|
|
|
expect((int) $victimAbo->fresh()->status)->toBe(2)
|
|
->and($victimAbo->fresh()->active)->toBeTruthy();
|
|
});
|
|
|
|
it('ignoriert ein gespooftes view=admin im Body: Mindestlaufzeit gilt weiterhin', function (): void {
|
|
$user = auaMakeUser();
|
|
$abo = auaMakeAbo($user, paidDeliveries: 2);
|
|
|
|
$this->actingAs($user);
|
|
|
|
auaCallUpdate('me', $abo->id, [
|
|
'action' => 'abo_update_settings',
|
|
'id' => $abo->id,
|
|
'view' => 'admin',
|
|
'abo_interval' => 5,
|
|
'abo_next_month' => now()->addMonth()->format('Y-m'),
|
|
'abo_cancel' => 'true',
|
|
]);
|
|
|
|
expect((int) $abo->fresh()->status)->toBe(2)
|
|
->and($abo->fresh()->active)->toBeTruthy();
|
|
});
|
|
|
|
it('ignoriert ein gespooftes view=admin im Body: Pause-Intervall gilt weiterhin', function (): void {
|
|
$user = auaMakeUser();
|
|
$abo = auaMakeAbo($user, lastPauseDate: '2026-06-01');
|
|
|
|
$this->actingAs($user);
|
|
|
|
auaCallUpdate('me', $abo->id, [
|
|
'action' => 'abo_update_settings',
|
|
'id' => $abo->id,
|
|
'view' => 'admin',
|
|
'abo_interval' => 5,
|
|
'abo_next_month' => now()->addMonth()->format('Y-m'),
|
|
'abo_pause' => 'true',
|
|
]);
|
|
|
|
expect($abo->fresh()->getRawOriginal('next_date'))->toStartWith('2026-08-05');
|
|
});
|
|
|
|
it('kündigt das eigene Abo nach erfüllter Mindestlaufzeit weiterhin regulär', function (): void {
|
|
$user = auaMakeUser();
|
|
$abo = auaMakeAbo($user);
|
|
|
|
$this->actingAs($user);
|
|
|
|
auaCallUpdate('me', $abo->id, [
|
|
'action' => 'abo_update_settings',
|
|
'id' => $abo->id,
|
|
'view' => 'me',
|
|
'abo_interval' => 5,
|
|
'abo_cancel' => 'true',
|
|
]);
|
|
|
|
expect((int) $abo->fresh()->status)->toBe(4)
|
|
->and($abo->fresh()->active)->toBeFalsy();
|
|
});
|
|
|
|
it('blockiert die Kündigung serverseitig vor erreichter Mindestlaufzeit', function (): void {
|
|
$user = auaMakeUser();
|
|
$abo = auaMakeAbo($user, paidDeliveries: 2);
|
|
|
|
$this->actingAs($user);
|
|
|
|
$repository = new AboRepository;
|
|
$repository->setModel($abo);
|
|
$repository->update([
|
|
'action' => 'abo_update_settings',
|
|
'id' => $abo->id,
|
|
'view' => 'me',
|
|
'abo_interval' => 5,
|
|
'abo_cancel' => 'true',
|
|
]);
|
|
|
|
expect((int) $abo->fresh()->status)->toBe(2)
|
|
->and($abo->fresh()->active)->toBeTruthy();
|
|
});
|
|
|
|
it('zeigt im Portal nach der Kündigung wieder die Abo-Neuanlage an', function (): void {
|
|
$shoppingUser = ShoppingUser::create([
|
|
'auth_user_id' => 1,
|
|
'billing_firstname' => 'Karla',
|
|
'billing_lastname' => 'Kundin',
|
|
'billing_address' => 'Kundenweg 2',
|
|
'billing_zipcode' => '11111',
|
|
'billing_city' => 'Kundenstadt',
|
|
'billing_country_id' => 1,
|
|
'shipping_country_id' => 1,
|
|
'billing_email' => 'aua-kundin@example.com',
|
|
'same_as_billing' => true,
|
|
'mode' => 'test',
|
|
'wp_order_number' => 0,
|
|
]);
|
|
|
|
UserAbo::create([
|
|
'user_id' => 1,
|
|
'member_id' => 1,
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'email' => 'aua-kundin@example.com',
|
|
'is_for' => 'ot',
|
|
'payone_userid' => 0,
|
|
'clearingtype' => 'cc',
|
|
'amount' => 5000,
|
|
'active' => false,
|
|
'status' => 4,
|
|
'abo_interval' => 5,
|
|
'cancel_date' => now(),
|
|
]);
|
|
|
|
$customer = Customer::create([
|
|
'name' => 'Karla Kundin',
|
|
'email' => 'aua-kundin@example.com',
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
]);
|
|
|
|
$this->actingAs($customer, 'customers');
|
|
|
|
$response = (new PortalAboController)->myAbo();
|
|
|
|
expect($response->name())->toBe('portal.abo.my_abo_create');
|
|
});
|