Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)

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>
This commit is contained in:
Kevin 2026-07-24 13:51:16 +00:00
parent 657d4bd5b7
commit ac3bd6e8ea
136 changed files with 7610 additions and 2688 deletions

View file

@ -52,6 +52,22 @@ it('löscht ein Abo soft über deleted_at', function () {
expect(UserAbo::withTrashed()->find($abo->id)->deleted_at)->not->toBeNull();
});
it('setzt beim Softdelete auch active auf false', function () {
$this->actingAs(adminDeleteUser());
$abo = makeUserAbo();
expect($abo->active)->toBeTrue();
try {
(new AboController(app(AboRepository::class)))->destroy($abo->id);
} catch (RouteNotFoundException $e) {
// Domain-spezifische Routen sind im Test-Kontext nicht registriert – erwartet.
}
$trashed = UserAbo::withTrashed()->find($abo->id);
expect($trashed->deleted_at)->not->toBeNull();
expect($trashed->active)->toBeFalse();
});
it('wirft eine Not-Found-Exception für ein nicht existierendes Abo', function () {
$this->actingAs(adminDeleteUser());

View file

@ -0,0 +1,94 @@
<?php
use App\Models\Setting;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\AboHelper;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function oarMakeUser(): User
{
return User::forceCreate([
'email' => 'oar-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
}
function oarMakeAbo(User $user, string $isFor, int $status, ?string $email = null): UserAbo
{
return UserAbo::create([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => 0,
'email' => $email ?? 'oar-abo-'.uniqid('', true).'@example.com',
'is_for' => $isFor,
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => $status === 2,
'status' => $status,
'abo_interval' => 5,
'start_date' => now()->subMonths(7),
'last_date' => now()->subMonth(),
'next_date' => now()->addWeek(),
'cancel_date' => in_array($status, [4, 5], true) ? now() : null,
]);
}
it('blockiert die Abo-Neuanlage nicht mehr durch gekuendigte oder beendete Abos', function (): void {
$user = oarMakeUser();
oarMakeAbo($user, 'me', 4);
expect(AboHelper::userHasAbo($user))->toBeFalse();
oarMakeAbo($user, 'me', 5);
expect(AboHelper::userHasAbo($user))->toBeFalse();
});
it('blockiert die Abo-Neuanlage weiterhin bei laufenden, pausierten oder angehaltenen Abos', function (int $status): void {
$user = oarMakeUser();
oarMakeAbo($user, 'me', $status);
expect(AboHelper::userHasAbo($user))->toBeTrue();
})->with([
'laufend (2)' => 2,
'angehalten (3)' => 3,
'pausiert (6)' => 6,
]);
it('ignoriert gekuendigte Kundenabos bei memberHasAbo und hasAboByEmail', function (): void {
$user = oarMakeUser();
$email = 'kunde-'.uniqid('', true).'@example.com';
oarMakeAbo($user, 'ot', 4, $email);
$shoppingUser = new ShoppingUser;
$shoppingUser->billing_email = $email;
expect(AboHelper::memberHasAbo($shoppingUser))->toBeFalse()
->and(AboHelper::hasAboByEmail($email))->toBeFalse();
oarMakeAbo($user, 'ot', 2, $email);
expect(AboHelper::memberHasAbo($shoppingUser))->toBeTrue()
->and(AboHelper::hasAboByEmail($email))->toBeTrue();
});
it('liefert 6 als Standard-Mindestlaufzeit ohne gepflegtes Setting', function (): void {
expect((int) AboHelper::getAboMinDuration())->toBe(AboHelper::DEFAULT_ABO_MIN_DURATION);
});
it('respektiert ein gepflegtes abo-min-duration Setting inklusive 0', function (): void {
Setting::setContentBySlug('abo-min-duration', 3, 'int');
expect((int) AboHelper::getAboMinDuration())->toBe(3);
Setting::setContentBySlug('abo-min-duration', 0, 'int');
expect((int) AboHelper::getAboMinDuration())->toBe(0);
});

View file

@ -1,10 +1,12 @@
<?php
use App\Models\UserAboItem;
use App\Models\UserAboOneTimeItem;
use App\Services\AboOrderCart;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(Tests\TestCase::class, RefreshDatabase::class);
uses(TestCase::class, RefreshDatabase::class);
describe('Abo Einmal-Produkte Views', function () {
beforeEach(fn () => makeShopEnv());
@ -193,6 +195,50 @@ describe('Abo Einmal-Produkte Views', function () {
$yard->destroy();
});
it('kennzeichnet den Versand in der Berater-Ansicht als netto und im Portal als brutto', function () {
$yard = Yard::instance(AboOrderCart::INSTANCE);
$yard->destroy();
$aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]);
Yard::setTax($aboRow->rowId, 19.0);
$summary = AboOrderCart::getSplitSummary();
$consultantHtml = view('admin.abo._order_combined_summary', ['summary' => $summary])->render();
$portalHtml = view('admin.abo._order_combined_summary', ['summary' => $summary, 'portal_view' => true])->render();
expect($consultantHtml)->toContain('('.__('order.net').')')
->and($consultantHtml)->not->toContain('('.__('order.incl_VAT').')')
->and($portalHtml)->toContain('('.__('order.incl_VAT').')')
->and($portalHtml)->not->toContain('('.__('order.net').')');
$yard->destroy();
});
it('kennzeichnet den Versand in der Abo-Artikelliste je nach Kontext als netto oder brutto', function () {
$abo = makeMeAbo();
$yard = Yard::instance(AboOrderCart::INSTANCE);
$yard->destroy();
$aboRow = $yard->add(1, 'Abo-Produkt', 1, 119.0, false, false, ['weight' => 100]);
Yard::setTax($aboRow->rowId, 19.0);
$base = [
'user_abo' => $abo,
'split_mode' => false,
'only_show_products' => false,
'add_only_mode' => false,
];
$consultantHtml = view('admin.abo._order_abo_show', $base)->render();
$portalHtml = view('admin.abo._order_abo_show', $base + ['portal_view' => true])->render();
expect($consultantHtml)->toContain('('.__('order.net').')')
->and($consultantHtml)->not->toContain('('.__('order.incl_VAT').')')
->and($portalHtml)->toContain('('.__('order.incl_VAT').')');
$yard->destroy();
});
it('rendert die Abo-Liste im Split-Modus mit Abo-Zwischensumme und Endsumme', function () {
$abo = makeMeAbo();
@ -242,7 +288,7 @@ describe('Abo Einmal-Produkte Views', function () {
it('rendert nach einer Mengenänderung die aktuelle (persistierte) Menge – kein Stale-Cache', function () {
$abo = makeMeAbo();
$product = makeProduct(['12'], 50, 0);
$item = \App\Models\UserAboItem::create([
$item = UserAboItem::create([
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1,
]);
@ -251,7 +297,7 @@ describe('Abo Einmal-Produkte Views', function () {
expect($abo->user_abo_items->first()->qty)->toBe(1);
// Menge über separate Instanz erhöhen (wie der Controller in updateCart/addProduct)
\App\Models\UserAboItem::find($item->id)->update(['qty' => 3]);
UserAboItem::find($item->id)->update(['qty' => 3]);
// Fix: Relation vor dem Render neu laden → gerenderte Menge = gespeicherter Stand
$abo->load('user_abo_items');
@ -267,7 +313,7 @@ describe('Abo Einmal-Produkte Views', function () {
it('blendet Minus/Entfernen im Add-only-Modus aus (Mindestlaufzeit nicht erfüllt)', function () {
$abo = makeMeAbo();
$product = makeProduct(['12'], 50, 0);
\App\Models\UserAboItem::create([
UserAboItem::create([
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 2, 'status' => 1,
]);
$abo->load('user_abo_items');
@ -292,7 +338,7 @@ describe('Abo Einmal-Produkte Views', function () {
it('friert alle Produkt-Steuerungen ein, wenn der Change-Lock greift', function () {
$abo = makeMeAbo();
$product = makeProduct(['12'], 50, 0);
\App\Models\UserAboItem::create([
UserAboItem::create([
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 2, 'status' => 1,
]);
$abo->load('user_abo_items');

View file

@ -0,0 +1,227 @@
<?php
use App\Models\Setting;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAboStatusHistory;
use App\Repositories\AboRepository;
use App\Services\AboHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-07-22'));
});
afterEach(function () {
Carbon::setTestNow();
});
function aptMakeUser(): User
{
return User::forceCreate([
'email' => 'apt-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
}
function aptMakeAbo(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' => 'apt-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();
}
function aptPause(UserAbo $abo, string $view = 'portal'): bool
{
$repository = new AboRepository;
$repository->setModel($abo);
$data = [
'action' => 'abo_update_settings',
'id' => $abo->id,
'view' => $view,
'abo_interval' => $abo->abo_interval,
'abo_pause' => 'true',
];
if ($view === 'admin') {
// Das Admin-Formular sendet den Ausfuehrungsmonat immer mit
$data['abo_next_month'] = now()->addMonth()->format('Y-m');
}
return $repository->update($data);
}
it('pausiert ein Abo um einen Monat und protokolliert die Aktion', function (): void {
$user = aptMakeUser();
$abo = aptMakeAbo($user);
expect(aptPause($abo))->toBeTrue();
$abo = $abo->fresh();
expect(Carbon::parse($abo->getRawOriginal('next_date'))->format('Y-m-d'))->toBe('2026-09-05')
->and(Carbon::parse($abo->getRawOriginal('last_pause_date'))->format('Y-m-d'))->toBe('2026-07-22')
->and($abo->pause_count)->toBe(1)
->and((int) $abo->status)->toBe(2)
->and($abo->active)->toBeTrue();
$history = UserAboStatusHistory::where('user_abo_id', $abo->id)->get();
expect($history)->toHaveCount(1)
->and($history[0]->action)->toBe(UserAboStatusHistory::ACTION_PAUSED)
->and($history[0]->view)->toBe('portal')
->and($history[0]->previous_next_date->format('Y-m-d'))->toBe('2026-08-05')
->and($history[0]->new_next_date->format('Y-m-d'))->toBe('2026-09-05');
});
it('lehnt die Pause vor Erreichen der Mindestlaufzeit ab', function (): void {
$user = aptMakeUser();
$abo = aptMakeAbo($user, paidDeliveries: 3); // Default-Mindestlaufzeit ist 6
expect(aptPause($abo))->toBeFalse();
expect(Carbon::parse($abo->fresh()->getRawOriginal('next_date'))->format('Y-m-d'))->toBe('2026-08-05')
->and($abo->fresh()->pause_count)->toBe(0)
->and(UserAboStatusHistory::count())->toBe(0);
});
it('lehnt die Pause innerhalb der Sperrfrist vor der Ausfuehrung ab', function (): void {
$user = aptMakeUser();
$abo = aptMakeAbo($user, nextDate: '2026-07-24'); // in 2 Tagen, Sperre = 3 Tage
expect(aptPause($abo))->toBeFalse()
->and($abo->fresh()->pause_count)->toBe(0);
});
it('erlaubt die Pause nur einmal pro rollierendem Intervall', function (): void {
$user = aptMakeUser();
// Letzte Pause vor 2 Monaten, Default-Intervall 6 Monate -> gesperrt
$abo = aptMakeAbo($user, lastPauseDate: '2026-05-22');
expect(AboHelper::canPauseAbo($abo, 'portal'))->toBeFalse()
->and(aptPause($abo))->toBeFalse()
->and(AboHelper::nextPossiblePauseDate($abo)->format('Y-m-d'))->toBe('2026-11-22');
// Verkuerztes Intervall per Setting -> wieder erlaubt
Setting::setContentBySlug('abo-pause-interval-months', 2, 'int');
expect(AboHelper::getPauseIntervalMonths())->toBe(2)
->and(AboHelper::canPauseAbo($abo->fresh(), 'portal'))->toBeTrue()
->and(aptPause($abo->fresh()))->toBeTrue();
});
it('erlaubt Admins die Pause ohne Regelpruefung', function (): void {
$user = aptMakeUser();
$abo = aptMakeAbo($user, paidDeliveries: 0, lastPauseDate: '2026-07-01');
expect(AboHelper::canPauseAbo($abo, 'admin'))->toBeTrue()
->and(aptPause($abo, 'admin'))->toBeTrue()
->and(Carbon::parse($abo->fresh()->getRawOriginal('next_date'))->format('Y-m-d'))->toBe('2026-09-05');
});
it('lehnt die Pause fuer inaktive oder gekuendigte Abos ab', function (): void {
$user = aptMakeUser();
$abo = aptMakeAbo($user);
$abo->update(['status' => 6, 'active' => false]);
expect(AboHelper::canPauseAbo($abo->fresh(), 'portal'))->toBeFalse()
->and(aptPause($abo->fresh()))->toBeFalse();
});
it('protokolliert die Kuendigung in der Status-Historie', function (): void {
$user = aptMakeUser();
$abo = aptMakeAbo($user);
$repository = new AboRepository;
$repository->setModel($abo);
$repository->update([
'action' => 'abo_update_settings',
'id' => $abo->id,
'view' => 'portal',
'abo_interval' => $abo->abo_interval,
'abo_cancel' => 'true',
]);
expect((int) $abo->fresh()->status)->toBe(4);
$history = UserAboStatusHistory::where('user_abo_id', $abo->id)->get();
expect($history)->toHaveCount(1)
->and($history[0]->action)->toBe(UserAboStatusHistory::ACTION_CANCELLED);
});
it('zeigt im Selfservice-Modal die Pause und Outline-Kuendigung ohne Aktiv-Schalter', function (): void {
app('request')->setLaravelSession(app('session.store'));
$user = aptMakeUser();
$abo = aptMakeAbo($user);
$html = view('admin.abo.modal_abo_update', [
'user_abo' => $abo,
'data' => ['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'portal'],
'route' => '/portal/abo/update',
])->render();
expect($html)
->toContain('abo_pause')
->toContain('btn-outline-danger')
->toContain(__('abo.cancel_abo'))
->not->toContain('switcher-input');
$adminHtml = view('admin.abo.modal_abo_update', [
'user_abo' => $abo,
'data' => ['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'admin'],
'route' => '/admin/abo/update',
])->render();
expect($adminHtml)->toContain('switcher-input')
->not->toContain('abo_pause');
});
it('zeigt die Pause im Admin-Aenderungsprotokoll', function (): void {
$user = aptMakeUser();
$abo = aptMakeAbo($user);
aptPause($abo);
$html = view('admin.abo._change_history', [
'user_abo' => $abo->fresh(),
'isAdmin' => true,
])->render();
expect($html)
->toContain(e(__('abo_history.status_history_title')))
->toContain(__('abo_history.status_paused'))
->toContain(__('abo_history.status_shift', ['from' => '05.08.2026', 'to' => '05.09.2026']))
->toContain(__('abo_history.channel_portal'));
});

View file

@ -0,0 +1,223 @@
<?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');
});

View file

@ -0,0 +1,81 @@
<?php
use App\Http\Controllers\Admin\AboController;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function adtMakeAboWithDeliveries(int $paidDeliveries): UserAbo
{
$user = User::forceCreate([
'email' => 'adt-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
$abo = UserAbo::create([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => 0,
'email' => 'adt-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-06-05',
'next_date' => '2026-08-05',
]);
for ($i = 0; $i < $paidDeliveries; $i++) {
UserAboOrder::create([
'user_abo_id' => $abo->id,
'shopping_order_id' => 0,
'status' => 2,
'paid' => true,
]);
}
return $abo;
}
it('sortiert die Admin-Abo-Tabelle nach Lieferungen ohne SQL-Fehler', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$aboFew = adtMakeAboWithDeliveries(1);
$aboMany = adtMakeAboWithDeliveries(3);
request()->merge([
'draw' => 1,
'start' => 0,
'length' => 10,
'search' => ['value' => '', 'regex' => 'false'],
'columns' => [
['data' => 'count', 'name' => 'count', 'searchable' => 'false', 'orderable' => 'true', 'search' => ['value' => '', 'regex' => 'false']],
],
'order' => [['column' => 0, 'dir' => 'desc']],
]);
$data = app(AboController::class)->datatable()->getData(true);
expect($data['data'])->toHaveCount(2)
->and((int) $data['data'][0]['count'])->toBe(3)
->and((int) $data['data'][1]['count'])->toBe(1);
request()->merge(['order' => [['column' => 0, 'dir' => 'asc']]]);
$data = app(AboController::class)->datatable()->getData(true);
expect((int) $data['data'][0]['count'])->toBe(1)
->and((int) $data['data'][1]['count'])->toBe(3);
});

View file

@ -0,0 +1,156 @@
<?php
use App\Http\Controllers\Api\ShoppingUserController;
use App\Models\Country;
use App\Models\Product;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShippingPrice;
use App\Models\ShoppingUser;
use App\Models\UserAccount;
use App\Models\UserShop;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Versandumgebung für API-Bestellungen: DE mit Gewichtsstufe 1–1000 g (4,90 €)
* und optionalem Versandkostenfrei-Schwellenwert auf der Shipping-Konfiguration.
*/
function makeApiShopEnv(?int $freeThreshold): array
{
$country = Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
$shipping = Shipping::create(['name' => 'DEUTSCHLAND', 'active' => true, 'free' => $freeThreshold ?? '']);
ShippingPrice::create([
'shipping_id' => $shipping->id, 'price' => 4.90, 'price_comp' => 0, 'num_comp' => 0,
'tax_rate' => 19, 'weight_from' => 1, 'weight_to' => 1000,
]);
$shippingCountry = ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
return [$country, $shippingCountry];
}
function makeApiOrderEnv(?int $freeThreshold = 80): ShoppingUser
{
[$country] = makeApiShopEnv($freeThreshold);
$account = UserAccount::create(['m_first_name' => 'Mia', 'm_last_name' => 'Vita', 'country_id' => $country->id]);
$member = User::forceCreate([
'email' => 'member-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'account_id' => $account->id,
]);
UserShop::create(['user_id' => $member->id, 'name' => 'Shop-'.uniqid('', true), 'active' => true]);
$apiUser = User::forceCreate([
'email' => 'api-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_sponsor' => $member->id,
]);
test()->actingAs($apiUser);
return ShoppingUser::create([
'member_id' => $member->id,
'auth_user_id' => 0,
'number' => 1,
'billing_firstname' => 'Max',
'billing_lastname' => 'Muster',
'billing_address' => 'Musterstr. 1',
'billing_zipcode' => '12345',
'billing_city' => 'Berlin',
'billing_country_id' => $country->id,
'billing_email' => 'kunde@example.com',
'shipping_country_id' => $country->id,
'same_as_billing' => true,
'wp_order_number' => 424242,
'mode' => 'dev',
]);
}
function makeApiProduct(float $price, int $weight = 500, bool $noFreeShipping = false): Product
{
$now = now();
$id = (int) DB::table('products')->insertGetId([
'name' => 'P-'.uniqid('', true),
'title' => 'Produkt',
'active' => true,
'price' => $price,
'tax' => 19,
'weight' => $weight,
'points' => 7,
'wp_number' => random_int(10000, 99999999),
'no_free_shipping' => $noFreeShipping,
'show_on' => json_encode(['2']),
'created_at' => $now,
'updated_at' => $now,
]);
return Product::findOrFail($id);
}
function callApiPrepareOrder(ShoppingUser $shoppingUser, array $items): void
{
Mail::fake();
$wpOrder = array_map(fn (Product $product) => [
'article' => $product->wp_number,
'qty' => 1,
'price' => (int) round($product->price * 100),
], $items);
$method = new ReflectionMethod(ShoppingUserController::class, 'prepareOrder');
$method->invoke(new ShoppingUserController, $wpOrder, $shoppingUser, null, []);
}
describe('API-Bestellung Versandkostenfrei-Schwellenwert', function () {
it('berechnet keine Versandkosten, wenn der Schwellenwert (DE 80 €) erreicht ist', function () {
$shoppingUser = makeApiOrderEnv(80);
$product = makeApiProduct(80.00);
callApiPrepareOrder($shoppingUser, [$product]);
$order = $shoppingUser->shopping_orders()->first();
expect((float) $order->shipping)->toBe(0.0)
->and((float) $order->total_shipping)->toBe(80.0);
});
it('berechnet Versandkosten, wenn der Schwellenwert nicht erreicht ist', function () {
$shoppingUser = makeApiOrderEnv(80);
$product = makeApiProduct(79.00);
callApiPrepareOrder($shoppingUser, [$product]);
$order = $shoppingUser->shopping_orders()->first();
expect((float) $order->shipping)->toBe(4.90)
->and((float) $order->total_shipping)->toBe(83.90);
});
it('berechnet Versandkosten, wenn kein Schwellenwert konfiguriert ist', function () {
$shoppingUser = makeApiOrderEnv(null);
$product = makeApiProduct(200.00);
callApiPrepareOrder($shoppingUser, [$product]);
$order = $shoppingUser->shopping_orders()->first();
expect((float) $order->shipping)->toBe(4.90);
});
it('berechnet Versandkosten nach Gewicht der no_free_shipping-Produkte trotz erreichtem Schwellenwert', function () {
$shoppingUser = makeApiOrderEnv(80);
$normal = makeApiProduct(80.00);
$excluded = makeApiProduct(20.00, 500, true);
callApiPrepareOrder($shoppingUser, [$normal, $excluded]);
$order = $shoppingUser->shopping_orders()->first();
expect((float) $order->shipping)->toBe(4.90);
});
});

View file

@ -5,6 +5,7 @@ use App\Models\ShoppingOrder;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\Services\Backoffice\PersonalStatisticsService;
use App\Services\BusinessPlan\LiveCommissionService;
use App\User;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -13,6 +14,18 @@ use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
// Der Controller holt die Live-Provision über den Container; ohne Datenbank-
// Schema (user_businesses) hier durch einen Fake mit Null-Werten ersetzen.
app()->instance(LiveCommissionService::class, new class extends LiveCommissionService
{
public function commission(User $user, int $month, int $year): array
{
return ['total' => 0.0, 'pp' => 0.0, 'shop' => 0.0, 'growth' => 0.0, 'is_live' => true];
}
});
});
function makeBackofficeStatisticsUser(int $admin): User
{
return (new User)->forceFill([
@ -213,10 +226,11 @@ it('erstellt einen CSV-Export fuer Detaildaten mit Karriere-Level und Summenzeil
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 0, 'shop_1000', 5, 2026)
->with($vip, 0, 'shop_1000', 5, 2026, false)
->andReturn([
'metric' => 'shop_1000',
'metric_label' => '1000 Punkte Shop',
'only_new' => false,
'line' => 0,
'line_label' => 'Alle Linien',
'month' => 5,
@ -283,10 +297,11 @@ it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function ()
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 1, 'consultants', 4, 2026)
->with($vip, 1, 'consultants', 4, 2026, false)
->andReturn([
'metric' => 'consultants',
'metric_label' => 'Berater',
'only_new' => false,
'line' => 1,
'line_label' => 'Linie 1',
'month' => 4,
@ -298,10 +313,11 @@ it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function ()
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 1, 'consultants', 4, 2026)
->with($vip, 1, 'consultants', 4, 2026, false)
->andReturn([
'metric' => 'consultants',
'metric_label' => 'Berater',
'only_new' => false,
'line' => 1,
'line_label' => 'Linie 1',
'month' => 4,

View file

@ -3,6 +3,8 @@
use App\Models\Product;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserSalesVolume;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\User;
@ -77,6 +79,17 @@ function boMakeAbo(int $memberId, string $isFor, string $startDate, int $points
return $abo->fresh();
}
function boMakeSalesVolume(int $userId, int $month, int $year, float $points, int $status): UserSalesVolume
{
return UserSalesVolume::forceCreate([
'user_id' => $userId,
'month' => $month,
'year' => $year,
'points' => $points,
'status' => $status,
]);
}
it('gruppiert Teamkundenabos pro Teampartner mit Punkten je Abo und gesamt', function (): void {
$root = boMakeUser();
$anna = boMakeUser($root->id);
@ -163,3 +176,81 @@ it('rendert die kombinierte Monatsstatistik-Card mit Abo- und Neupartner-Kennzah
->toContain('user_abos_team_show')
->toContain('+1'); // "+neu"-Badge im gewählten Monat
});
it('zeigt im Neupartner-Drilldown Sponsor und tagesaktuelle Kundenpunkte', function (): void {
$root = boMakeUser();
$sponsor = boMakeUser($root->id);
$partner = boMakeUser($sponsor->id, '2026-06-15');
boMakeSalesVolume($partner->id, 6, 2026, 250, 2); // Kundenpunkte (Shop)
boMakeSalesVolume($partner->id, 6, 2026, 100, 1); // Eigenpunkte zaehlen nicht als Kundenpunkte
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 6, 2026);
expect($details['rows'])->toHaveCount(1)
->and($details['rows'][0]['sponsor'])->toBe($sponsor->email)
->and($details['rows'][0]['customer_points'])->toBe(250.0)
->and($details['summary']['customer_points'])->toBe(250.0);
});
it('zaehlt den Betrachter selbst beim 1000-Punkte-Shop mit', function (): void {
$root = boMakeUser();
$partner = boMakeUser($root->id);
boMakeSalesVolume($root->id, 6, 2026, 1200, 2);
boMakeSalesVolume($partner->id, 6, 2026, 1500, 2);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'shop_1000', 6, 2026);
expect($details['rows'])->toHaveCount(2);
$selfRow = collect($details['rows'])->firstWhere('user_id', $root->id);
expect($selfRow['name'])->toContain('(ich)');
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
expect($overview['totals']['shop_1000'])->toBe(2)
->and($overview['lines'][1]['shop_1000'])->toBe(1);
});
it('zaehlt den Berater selbst bei den Dashboard-KPIs zum 1000-Punkte-Shop mit', function (): void {
$root = boMakeUser();
boMakeSalesVolume($root->id, 6, 2026, 1100, 2);
// auch ganz ohne Team zaehlt der eigene 1000-Punkte-Shop
$kpis = app(TeamAboOverviewService::class)->teamKpis($root, 6, 2026);
expect($kpis['shop_1000'])->toBe(1);
$partner = boMakeUser($root->id);
boMakeSalesVolume($partner->id, 6, 2026, 1300, 2);
$kpis = app(TeamAboOverviewService::class)->teamKpis($root->fresh(), 6, 2026);
expect($kpis['shop_1000'])->toBe(2);
});
it('filtert Abo-Drilldowns mit only_new auf die im Monat neuen Abos', function (): void {
$root = boMakeUser();
$anna = boMakeUser($root->id);
$lena = boMakeUser($root->id);
boMakeAbo($anna->id, 'me', '2026-06-10', 20); // neu im Juni
boMakeAbo($lena->id, 'me', '2026-05-10', 30); // Bestand
$service = app(BackofficeDrilldownService::class);
$all = $service->details($root, 0, 'team_partner_abos', 6, 2026);
$onlyNew = $service->details($root, 0, 'team_partner_abos', 6, 2026, true);
expect($all['rows'])->toHaveCount(2)
->and($all['only_new'])->toBeFalse()
->and($onlyNew['rows'])->toHaveCount(1)
->and($onlyNew['rows'][0]['user_id'])->toBe($anna->id)
->and($onlyNew['only_new'])->toBeTrue()
->and($onlyNew['metric_label'])->toContain('nur neue');
boMakeAbo($anna->id, 'ot', '2026-06-12', 10); // neues Kundenabo
boMakeAbo($anna->id, 'ot', '2026-04-01', 10); // Bestand
$onlyNewCustomers = $service->details($root, 0, 'team_customer_abos', 6, 2026, true);
expect($onlyNewCustomers['rows'])->toHaveCount(1)
->and($onlyNewCustomers['rows'][0]['abo_count'])->toBe(1);
});

View file

@ -1,7 +1,11 @@
<?php
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserLevel;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -9,6 +13,14 @@ use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
Schema::connection('sqlite')->table('products', function ($table) {
$table->string('slug')->nullable();
});
}
});
function bslMakeLevel(string $name, int $pos): UserLevel
{
return UserLevel::forceCreate([
@ -52,6 +64,33 @@ function bslMakeAbo(int $userId, string $isFor, string $startDate, int $status =
]);
}
function bslMakeAboWithPoints(int $userId, string $isFor, string $startDate, int $points): UserAbo
{
$abo = bslMakeAbo($userId, $isFor, $startDate);
$product = Product::forceCreate([
'name' => 'BSL-'.uniqid('', true),
'title' => 'Test',
'price' => 10.0,
'tax' => 19.0,
'active' => true,
'pos' => 1,
'weight' => 100,
'points' => $points,
'slug' => 'bsl-'.uniqid('', true),
]);
UserAboItem::create([
'user_abo_id' => $abo->id,
'product_id' => $product->id,
'comp' => 0,
'qty' => 1,
'status' => 1,
]);
return $abo->fresh();
}
it('rankt neue Kundenabos pro Berater absteigend inklusive Linie', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
@ -121,3 +160,276 @@ it('liefert eine leere Fuehrungskraefte-Liste, wenn die Stufe Vertriebsleiter ni
expect($overview['leader_abos']['total'])->toBe(0)
->and($overview['leader_abos']['rows'])->toBe([]);
});
it('ergaenzt die Fuehrungskraefte-Liste um Teamzahlen mit Abschneiden an der naechsten Fuehrungskraft', function (): void {
$berater = bslMakeLevel('Berater', 3);
$vertriebsleiter = bslMakeLevel('Vertriebsleiter', 6);
$root = bslMakeUser();
$leaderA = bslMakeUser($root->id, $vertriebsleiter->id);
$member1 = bslMakeUser($leaderA->id, $berater->id);
$leaderB = bslMakeUser($member1->id, $vertriebsleiter->id);
$member2 = bslMakeUser($leaderB->id, $berater->id);
bslMakeAbo($leaderA->id, 'me', '2026-06-01'); // eigenes Abo Leader A
bslMakeAbo($member1->id, 'me', '2026-06-01'); // Teamabo von A
bslMakeAbo($member1->id, 'ot', '2026-06-01'); // Team-Kundenabo von A
bslMakeAbo($leaderB->id, 'ot', '2026-06-01'); // eigenes Kundenabo Leader B
bslMakeAbo($member2->id, 'me', '2026-06-01'); // Teamabo von B, zaehlt NICHT bei A
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
$leaderAbos = $overview['leader_abos'];
$rows = collect($leaderAbos['rows'])->keyBy('user_id');
$a = $rows->get($leaderA->id);
$b = $rows->get($leaderB->id);
expect($a['own_abo_count'])->toBe(1)
->and($a['customer_abo_count'])->toBe(0)
->and($a['team_abo_count'])->toBe(1)
->and($a['team_customer_abo_count'])->toBe(1)
->and($a['total_abo_count'])->toBe(3)
->and($b['own_abo_count'])->toBe(0)
->and($b['customer_abo_count'])->toBe(1)
->and($b['team_abo_count'])->toBe(1)
->and($b['team_customer_abo_count'])->toBe(0)
->and($b['total_abo_count'])->toBe(2);
expect($leaderAbos['total_own'])->toBe(1)
->and($leaderAbos['total_customer'])->toBe(1)
->and($leaderAbos['total_team'])->toBe(2)
->and($leaderAbos['total_team_customer'])->toBe(1)
->and($leaderAbos['total'])->toBe(5);
// Sortierung nach Gesamtsumme: Leader A (3) vor Leader B (2)
expect($leaderAbos['rows'][0]['user_id'])->toBe($leaderA->id);
});
it('liefert Top-10-Kundenabo-Ranglisten nach Anzahl und Umsatz mit Soll und Ist', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
$lena = bslMakeUser($root->id);
bslMakeAboWithPoints($anna->id, 'ot', '2026-06-01', 20);
bslMakeAboWithPoints($anna->id, 'ot', '2026-05-01', 30);
bslMakeAboWithPoints($lena->id, 'ot', '2026-04-01', 80);
bslMakeAbo($lena->id, 'me', '2026-06-01'); // Eigenabo zaehlt nicht
// Ist-Umsatz: im Juni abgerechnete Kundenabo-Punkte fuer Lena
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => $lena->id,
'country_id' => 0,
'user_shop_id' => 0,
'is_abo' => true,
'paid' => true,
]);
UserSalesVolume::forceCreate([
'user_id' => $lena->id,
'shopping_order_id' => $order->id,
'month' => 6,
'year' => 2026,
'points' => 80,
'status' => 2,
]);
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
$top = $overview['top_customer_abos'];
expect($top['by_count'])->toHaveCount(2)
->and($top['by_count'][0]['user_id'])->toBe($anna->id)
->and($top['by_count'][0]['abo_count'])->toBe(2)
->and($top['by_count'][1]['user_id'])->toBe($lena->id)
->and($top['by_count'][1]['abo_count'])->toBe(1);
expect($top['by_points'][0]['user_id'])->toBe($lena->id)
->and($top['by_points'][0]['points_target'])->toBe(80.0)
->and($top['by_points'][0]['points_actual'])->toBe(80.0)
->and($top['by_points'][1]['user_id'])->toBe($anna->id)
->and($top['by_points'][1]['points_target'])->toBe(50.0)
->and($top['by_points'][1]['points_actual'])->toBe(0.0);
});
it('ueberlagert Snapshot-Monate mit tagesaktuellen Abo-Bestandszahlen', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
$abo = bslMakeAbo($anna->id, 'me', '2026-05-15');
$service = app(BackofficeDashboardService::class);
$service->storeSnapshot($root, 5, 2026);
// Nach dem Snapshot: Teamabo gekuendigt, neues Kundenabo dazugekommen
$abo->update(['status' => 4, 'active' => false]);
bslMakeAbo($anna->id, 'ot', '2026-07-01');
$overview = $service->overview($root, 5, 2026);
expect($overview['_meta']['source'])->toBe('snapshot')
->and($overview['abo_stock_live'])->toBeTrue()
->and($overview['lines'][1]['team_partner_abos'])->toBe(0) // Bestand: live
->and($overview['lines'][1]['team_customer_abos'])->toBe(1) // Bestand: live
->and($overview['lines'][1]['team_partner_abos_new'])->toBe(1) // "+N neu" im Mai: eingefroren
->and($overview['totals']['team_partner_abos'])->toBe(0)
->and($overview['totals']['team_customer_abos'])->toBe(1);
});
it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden sind', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
bslMakeAbo($anna->id, 'me', '2026-05-15');
$service = app(BackofficeDashboardService::class);
$service->storeSnapshot($root, 5, 2026);
// Nach dem Snapshot: Team wird tiefer — Linie 2 entsteht und bringt Abo-Bestand mit
$ben = bslMakeUser($anna->id);
bslMakeAbo($ben->id, 'me', '2026-07-01');
bslMakeAbo($ben->id, 'ot', '2026-07-02');
$overview = $service->overview($root, 5, 2026);
expect($overview['_meta']['source'])->toBe('snapshot')
->and($overview['lines'])->toHaveKey(2)
->and($overview['lines'][2]['team_partner_abos'])->toBe(1)
->and($overview['lines'][2]['team_customer_abos'])->toBe(1)
->and($overview['lines'][2]['team_partner_abos_new'])->toBe(0) // Monatswert: im Snapshot-Monat gab es die Linie nicht
->and($overview['totals']['team_partner_abos'])->toBe(2)
->and($overview['totals']['team_customer_abos'])->toBe(1);
});
it('nimmt den Statistik-Nutzer selbst in die Kundenabo-Ranglisten auf', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
bslMakeAboWithPoints($root->id, 'ot', '2026-06-10', 25);
bslMakeAboWithPoints($anna->id, 'ot', '2026-06-05', 15);
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
$newRanking = collect($overview['new_customer_abos'])->keyBy('user_id');
expect($newRanking->has($root->id))->toBeTrue()
->and($newRanking->get($root->id)['is_self'])->toBeTrue()
->and($newRanking->get($root->id)['line'])->toBe(0)
->and($newRanking->get($anna->id)['is_self'])->toBeFalse();
$byCount = collect($overview['top_customer_abos']['by_count'])->keyBy('user_id');
expect($byCount->has($root->id))->toBeTrue()
->and($byCount->get($root->id)['is_self'])->toBeTrue();
$byPoints = collect($overview['top_customer_abos']['by_points'])->keyBy('user_id');
expect($byPoints->has($root->id))->toBeTrue()
->and($byPoints->get($root->id)['points_target'])->toBe(25.0);
});
it('sortiert die Top-10-Umsatzliste nach abgerechneten Ist-Punkten und liefert die Abo-Anzahl mit', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id); // hoher Bestand, nichts abgerechnet
$lena = bslMakeUser($root->id); // kleiner Bestand, viel abgerechnet
bslMakeAboWithPoints($anna->id, 'ot', '2026-06-01', 100);
bslMakeAboWithPoints($lena->id, 'ot', '2026-06-01', 30);
bslMakeAboWithPoints($lena->id, 'ot', '2026-06-02', 30);
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => $lena->id,
'country_id' => 0,
'user_shop_id' => 0,
'is_abo' => true,
'paid' => true,
]);
UserSalesVolume::forceCreate([
'user_id' => $lena->id,
'shopping_order_id' => $order->id,
'month' => 6,
'year' => 2026,
'points' => 90,
'status' => 2,
]);
$byPoints = app(BackofficeDashboardService::class)->overview($root, 6, 2026)['top_customer_abos']['by_points'];
expect($byPoints[0]['user_id'])->toBe($lena->id)
->and($byPoints[0]['points_actual'])->toBe(90.0)
->and($byPoints[0]['points_target'])->toBe(60.0)
->and($byPoints[0]['abo_count'])->toBe(2)
->and($byPoints[1]['user_id'])->toBe($anna->id)
->and($byPoints[1]['points_target'])->toBe(100.0)
->and($byPoints[1]['abo_count'])->toBe(1);
});
it('liefert je Fuehrungskraft zusaetzlich die Teamzahlen der kompletten Downline', function (): void {
$berater = bslMakeLevel('Berater', 3);
$vertriebsleiter = bslMakeLevel('Vertriebsleiter', 6);
$root = bslMakeUser();
$leaderA = bslMakeUser($root->id, $vertriebsleiter->id);
$member1 = bslMakeUser($leaderA->id, $berater->id);
$leaderB = bslMakeUser($member1->id, $vertriebsleiter->id);
$member2 = bslMakeUser($leaderB->id, $berater->id);
bslMakeAbo($leaderA->id, 'me', '2026-06-01');
bslMakeAbo($member1->id, 'me', '2026-06-01');
bslMakeAbo($member1->id, 'ot', '2026-06-01');
bslMakeAbo($leaderB->id, 'ot', '2026-06-01');
bslMakeAbo($member2->id, 'me', '2026-06-01'); // zaehlt bei A nur in der Gesamt-Sicht
$rows = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['leader_abos']['rows'])->keyBy('user_id');
$a = $rows->get($leaderA->id);
$b = $rows->get($leaderB->id);
// Direkt (an Leader B abgeschnitten) bleibt unveraendert additiv
expect($a['team_abo_count'])->toBe(1)
->and($a['team_customer_abo_count'])->toBe(1)
// Gesamt: komplette Downline inkl. Leader B und dessen Team
->and($a['team_abo_count_total'])->toBe(2)
->and($a['team_customer_abo_count_total'])->toBe(2)
->and($a['total_abo_count_full'])->toBe(5)
->and($b['team_abo_count_total'])->toBe(1)
->and($b['total_abo_count_full'])->toBe(2);
});
it('summiert Punkte ohne Payline-Wirkung (status_points 2) als eigene Kennzahl', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
UserSalesVolume::forceCreate([
'user_id' => $anna->id,
'month' => 6,
'year' => 2026,
'points' => 200,
'status' => 5,
'status_points' => 2, // Neupartner-Einschreibung: nur KP, nicht Payline
]);
UserSalesVolume::forceCreate([
'user_id' => $anna->id,
'month' => 6,
'year' => 2026,
'points' => 150,
'status' => 1,
'status_points' => 1, // normale Bestellung: payline-wirksam
]);
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
expect($overview['non_payline_points'])->toBe(200.0)
->and($overview['totals']['total_points'])->toBe(350.0);
});
it('ignoriert Abo-Punkte geloeschter Produkte im Soll der Top-10-Umsatzliste', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
$aboLive = bslMakeAboWithPoints($anna->id, 'ot', '2026-06-01', 40);
$aboDeleted = bslMakeAboWithPoints($anna->id, 'ot', '2026-06-02', 60);
Product::whereKey(UserAboItem::where('user_abo_id', $aboDeleted->id)->value('product_id'))->first()->delete();
$overview = app(BackofficeDashboardService::class)->overview($root, 7, 2026);
$byPoints = $overview['top_customer_abos']['by_points'];
expect($byPoints)->toHaveCount(1)
->and($byPoints[0]['user_id'])->toBe($anna->id)
->and($byPoints[0]['points_target'])->toBe(40.0);
});

View file

@ -4,15 +4,19 @@ use App\Console\Commands\BusinessStoreOptimized;
use App\Models\UserLevel;
use App\Models\UserSalesVolume;
use App\User;
use Illuminate\Console\OutputStyle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Tests\TestCase;
uses(Tests\TestCase::class, RefreshDatabase::class);
uses(TestCase::class, RefreshDatabase::class);
function makeCommand(): BusinessStoreOptimized
{
$command = new BusinessStoreOptimized;
$input = new \Symfony\Component\Console\Input\ArrayInput([]);
$output = new \Illuminate\Console\OutputStyle($input, new \Symfony\Component\Console\Output\NullOutput);
$input = new ArrayInput([]);
$output = new OutputStyle($input, new NullOutput);
$command->setOutput($output);
return $command;
@ -36,7 +40,7 @@ function makeUserLevel(int $qualKp, int $pos = 7): UserLevel
]);
}
function makeUser(int $levelId, ?int $forceId = null): User
function makeLevelUser(int $levelId, ?int $forceId = null): User
{
$attributes = [
'email' => fake()->unique()->safeEmail(),
@ -54,7 +58,7 @@ function makeUser(int $levelId, ?int $forceId = null): User
it('assigns qual_kp points to user 486 when their level has qual_kp > 0', function () {
$level = makeUserLevel(690);
makeUser($level->id, 486);
makeLevelUser($level->id, 486);
callAssignMethod(makeCommand());
@ -69,7 +73,7 @@ it('assigns qual_kp points to user 486 when their level has qual_kp > 0', functi
it('does not assign bonus points to users not in the bonus list', function () {
$level = makeUserLevel(690);
$otherUser = makeUser($level->id);
$otherUser = makeLevelUser($level->id);
callAssignMethod(makeCommand());
@ -79,7 +83,7 @@ it('does not assign bonus points to users not in the bonus list', function () {
it('skips user 486 when their level has qual_kp of zero', function () {
$level = makeUserLevel(0);
makeUser($level->id, 486);
makeLevelUser($level->id, 486);
callAssignMethod(makeCommand());
@ -104,7 +108,7 @@ it('skips user 486 when they have no level assigned', function () {
it('is idempotent and does not create duplicate entries for the same month', function () {
$level = makeUserLevel(690);
makeUser($level->id, 486);
makeLevelUser($level->id, 486);
$command = makeCommand();
callAssignMethod($command);

View file

@ -0,0 +1,147 @@
<?php
use App\Models\Country;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\UserAccount;
use App\Services\UserService;
use App\Services\VatIdService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use function Pest\Laravel\mock;
uses(TestCase::class, RefreshDatabase::class);
function makeVatCheckoutUser(array $accountOverrides = []): array
{
$country = Country::create([
'code' => 'AT',
'phone' => '43',
'en' => 'Austria',
'de' => 'Österreich',
'es' => 'Austria',
'fr' => 'Autriche',
'it' => 'Austria',
'ru' => 'Австрия',
'eu_country' => true,
]);
$shipping = Shipping::create(['name' => 'Österreich', 'active' => true]);
$shippingCountry = ShippingCountry::create([
'shipping_id' => $shipping->id,
'country_id' => $country->id,
]);
$account = UserAccount::create(array_merge([
'first_name' => 'Marlene',
'last_name' => 'Mathis',
'country_id' => $country->id,
'tax_identification_number' => 'ATU64282555',
'taxable_sales' => 1,
], $accountOverrides));
// reverse_charge* sind nicht fillable -> explizit setzen
$account->forceFill(array_merge([
'reverse_charge' => 1,
'reverse_charge_valid' => now(),
'reverse_charge_code' => 'AT',
], array_intersect_key($accountOverrides, array_flip(['reverse_charge', 'reverse_charge_valid', 'reverse_charge_code']))))->save();
$user = User::forceCreate([
'account_id' => $account->id,
'email' => 'vat-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
return [$user->fresh(), $shippingCountry];
}
beforeEach(function () {
UserService::$user_reverse_charge = false;
});
it('keeps tax free status when vies is unavailable', function () {
// VIES_ENABLED=false in phpunit.xml -> CHECK_UNAVAILABLE -> gespeicherter Status gilt
[$user, $shippingCountry] = makeVatCheckoutUser();
$result = UserService::performUserTaxShippingCountry($user, $shippingCountry);
expect($result)->toBeTrue();
expect(UserService::$user_reverse_charge)->toBeTrue();
expect($user->account->fresh()->reverse_charge)->toBeTruthy();
});
it('revokes reverse charge for german vat ids', function () {
[$user, $shippingCountry] = makeVatCheckoutUser([
'tax_identification_number' => 'DE123456789',
]);
$result = UserService::performUserTaxShippingCountry($user, $shippingCountry);
expect($result)->toBeFalse();
expect(UserService::$user_reverse_charge)->toBeFalse();
$account = $user->account->fresh();
expect($account->reverse_charge)->toBeFalsy();
expect($account->reverse_charge_valid)->toBeNull();
});
it('revokes reverse charge for implausible vat ids', function () {
[$user, $shippingCountry] = makeVatCheckoutUser([
'tax_identification_number' => '53618009',
]);
$result = UserService::performUserTaxShippingCountry($user, $shippingCountry);
expect($result)->toBeFalse();
expect($user->account->fresh()->reverse_charge)->toBeFalsy();
});
it('revokes reverse charge when vies reports the id as invalid', function () {
[$user, $shippingCountry] = makeVatCheckoutUser();
mock(VatIdService::class, function ($mock) {
$mock->shouldReceive('qualifiesForReverseCharge')->andReturn(true);
$mock->shouldReceive('checkViesCached')->andReturn(VatIdService::CHECK_INVALID);
});
$result = UserService::performUserTaxShippingCountry($user, $shippingCountry);
expect($result)->toBeFalse();
$account = $user->account->fresh();
expect($account->reverse_charge)->toBeFalsy();
expect($account->reverse_charge_valid)->toBeNull();
// Die USt-ID selbst bleibt erhalten, damit der User sie neu validieren kann
expect($account->tax_identification_number)->toBe('ATU64282555');
});
it('grants tax free checkout when vies confirms the id', function () {
[$user, $shippingCountry] = makeVatCheckoutUser();
mock(VatIdService::class, function ($mock) {
$mock->shouldReceive('qualifiesForReverseCharge')->andReturn(true);
$mock->shouldReceive('checkViesCached')->andReturn(VatIdService::CHECK_VALID);
});
$result = UserService::performUserTaxShippingCountry($user, $shippingCountry);
expect($result)->toBeTrue();
expect(UserService::$user_reverse_charge)->toBeTrue();
expect($user->account->fresh()->reverse_charge)->toBeTruthy();
});
it('does not run vies check when shipping country does not match reverse charge code', function () {
[$user, $shippingCountry] = makeVatCheckoutUser([
'reverse_charge_code' => 'ES',
]);
$result = UserService::performUserTaxShippingCountry($user, $shippingCountry);
expect($result)->toBeFalse();
// Kein Entzug: Der Status bleibt für passende Lieferländer bestehen
expect($user->account->fresh()->reverse_charge)->toBeTruthy();
});

View file

@ -1,109 +0,0 @@
<?php
use App\Http\Controllers\Api\KasController;
use Tests\TestCase;
uses(TestCase::class);
it('erstellt ohne force nur eine temporäre kas liste', function () {
$listFile = sys_get_temp_dir().'/kas-subdomains-dry-run-test-'.uniqid().'.json';
$kas = \Mockery::mock(KasController::class);
$kas->shouldReceive('action')
->once()
->with('get_subdomains')
->andReturn([
['subdomain_name' => 'api.mivita.care', 'subdomain_path' => '/api/public/', 'php_version' => '8.2'],
['subdomain_name' => 'shop.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/', 'php_version' => '8.2'],
]);
$this->app->instance(KasController::class, $kas);
$this->artisan('subdomains:delete-all-kas', [
'--list-file' => $listFile,
])->assertSuccessful();
$data = json_decode(file_get_contents($listFile), true);
expect(file_exists($listFile))->toBeTrue();
expect($data['count'])->toBe(2);
expect($data['subdomains'])->toHaveCount(2);
expect($data['mode'])->toBe('dry-run');
@unlink($listFile);
});
it('löscht mit force alle subdomains aus der kas liste ohne datenbankabgleich', function () {
$listFile = sys_get_temp_dir().'/kas-subdomains-force-test-'.uniqid().'.json';
$kas = \Mockery::mock(KasController::class);
$kas->shouldReceive('action')
->once()
->with('get_subdomains')
->andReturn([
['subdomain_name' => 'api.mivita.care', 'subdomain_path' => '/api/public/'],
['subdomain_name' => 'preview.mivita.care', 'subdomain_path' => '/preview/public/'],
['subdomain_name' => 'partner.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'],
]);
$kas->shouldReceive('action')
->once()
->with('delete_subdomain', ['subdomain_name' => 'api.mivita.care'])
->andReturn('TRUE');
$kas->shouldReceive('action')
->once()
->with('delete_subdomain', ['subdomain_name' => 'partner.mivita.care'])
->andReturn('TRUE');
$kas->shouldReceive('action')
->once()
->with('delete_subdomain', ['subdomain_name' => 'preview.mivita.care'])
->andReturn('TRUE');
$this->app->instance(KasController::class, $kas);
$this->artisan('subdomains:delete-all-kas', [
'--force' => true,
'--list-file' => $listFile,
])->assertSuccessful();
$data = json_decode(file_get_contents($listFile), true);
expect($data['count'])->toBe(3);
expect($data['mode'])->toBe('delete');
@unlink($listFile);
});
it('kann optional auf eine hauptdomain und ein limit begrenzen', function () {
$listFile = sys_get_temp_dir().'/kas-subdomains-filter-test-'.uniqid().'.json';
$kas = \Mockery::mock(KasController::class);
$kas->shouldReceive('action')
->once()
->with('get_subdomains')
->andReturn([
['subdomain_name' => 'a.mivita.care'],
['subdomain_name' => 'b.mivita.care'],
['subdomain_name' => 'extern.example.com'],
]);
$kas->shouldReceive('action')
->once()
->with('delete_subdomain', ['subdomain_name' => 'a.mivita.care'])
->andReturn('TRUE');
$this->app->instance(KasController::class, $kas);
$this->artisan('subdomains:delete-all-kas', [
'--force' => true,
'--domain' => 'mivita.care',
'--limit' => 1,
'--list-file' => $listFile,
])->assertSuccessful();
$data = json_decode(file_get_contents($listFile), true);
expect($data['count'])->toBe(1);
expect($data['domain_filter'])->toBe('mivita.care');
expect($data['subdomains'])->toHaveCount(1);
@unlink($listFile);
});

View file

@ -1,113 +0,0 @@
<?php
use App\Http\Controllers\Api\KasController;
use App\Models\UserShop;
use App\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Schema::dropIfExists('user_shops');
Schema::dropIfExists('users');
Schema::create('users', function (Blueprint $table) {
$table->increments('id');
$table->string('email')->unique();
$table->string('password');
$table->char('lang', 2)->default('de');
$table->timestamps();
$table->softDeletes();
});
Schema::create('user_shops', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('user_id');
$table->string('name', 20)->unique();
$table->string('slug', 40)->unique();
$table->boolean('active')->default(false);
$table->timestamps();
$table->softDeletes();
});
});
afterEach(function () {
Schema::dropIfExists('user_shops');
Schema::dropIfExists('users');
});
it('führt ohne force nur einen dry run aus', function () {
UserShop::forceCreate([
'user_id' => User::forceCreate([
'email' => 'partner@example.test',
'password' => 'secret',
'lang' => 'de',
])->id,
'name' => 'Partner Shop',
'slug' => 'partner',
'active' => true,
]);
$kas = \Mockery::mock(KasController::class);
$kas->shouldReceive('action')
->once()
->with('get_subdomains')
->andReturn([
['subdomain_name' => 'partner.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'],
]);
$this->app->instance(KasController::class, $kas);
$this->artisan('subdomains:delete-partnershops')->assertSuccessful();
});
it('löscht passende partnershop subdomains mit force', function () {
UserShop::forceCreate([
'user_id' => User::forceCreate([
'email' => 'shop@example.test',
'password' => 'secret',
'lang' => 'de',
])->id,
'name' => 'Shop Eins',
'slug' => 'shop-eins',
'active' => true,
]);
$kas = \Mockery::mock(KasController::class);
$kas->shouldReceive('action')
->once()
->with('get_subdomains')
->andReturn([
['subdomain_name' => 'shop-eins.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'],
['subdomain_name' => 'api.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'],
['subdomain_name' => 'extern.mivita.care', 'subdomain_path' => '/andere-app/public/'],
]);
$kas->shouldReceive('action')
->once()
->with('delete_subdomain', ['subdomain_name' => 'shop-eins.mivita.care'])
->andReturn('TRUE');
$this->app->instance(KasController::class, $kas);
$this->artisan('subdomains:delete-partnershops --force')->assertSuccessful();
});
it('kann verwaiste partnershop subdomains über den kas pfad löschen', function () {
$kas = \Mockery::mock(KasController::class);
$kas->shouldReceive('action')
->once()
->with('get_subdomains')
->andReturn([
['subdomain_name' => 'alte-partnerin.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'],
['subdomain_name' => 'preview.mivita.care', 'subdomain_path' => '/mein.mivita.care/public/'],
]);
$kas->shouldReceive('action')
->once()
->with('delete_subdomain', ['subdomain_name' => 'alte-partnerin.mivita.care'])
->andReturn('TRUE');
$this->app->instance(KasController::class, $kas);
$this->artisan('subdomains:delete-partnershops --force --include-path-matches')->assertSuccessful();
});

View file

@ -0,0 +1,371 @@
<?php
use App\Exceptions\GdprDeletionException;
use App\Models\Country;
use App\Models\File;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserBusiness;
use App\Models\UserShop;
use App\Models\UserUpdateEmail;
use App\Services\GdprDeletionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function gdprCountry(): Country
{
return Country::create([
'code' => 'DE',
'phone' => '49',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Германия',
]);
}
function gdprConsultant(array $userAttributes = []): User
{
$account = UserAccount::create([
'm_first_name' => 'Max',
'm_last_name' => 'Mustermann',
'first_name' => 'Max',
'last_name' => 'Mustermann',
'address' => 'Musterstraße 1',
'zipcode' => '12345',
'city' => 'Musterstadt',
'phone' => '123456',
'mobil' => '987654',
'tax_number' => '12/345/67890',
'tax_identification_number' => 'DE123456789',
'birthday' => '1990-05-01',
'bank_owner' => 'Max Mustermann',
'bank_iban' => 'DE02120300000000202051',
'bank_bic' => 'BYLADEM1001',
'website' => 'https://example.com',
'facebook' => 'max.mustermann',
]);
$user = User::forceCreate(array_merge([
'email' => 'gdpr-user-'.uniqid('', true).'@example.com',
'password' => Hash::make('secret'),
'lang' => 'de',
'account_id' => $account->id,
'notes' => 'Interne Notiz',
], $userAttributes));
return $user->fresh();
}
it('anonymisiert alle Stammdaten eines Beraters unwiderruflich', function () {
Mail::fake();
Storage::fake('user');
Storage::fake('public');
$user = gdprConsultant();
$originalEmail = $user->email;
Storage::disk('user')->put('uploads/'.$user->id.'/ausweis.pdf', 'pdf-content');
$file = File::create([
'user_id' => $user->id,
'identifier' => 'id_card',
'filename' => 'ausweis.pdf',
'dir' => 'uploads/'.$user->id.'/',
'original_name' => 'ausweis.pdf',
'ext' => 'pdf',
'mine' => 'application/pdf',
'size' => 11,
]);
DB::table('user_update_emails')->insert([
'user_id' => $user->id,
'email' => 'neue-mail@example.com',
'token' => 'token123',
'created_at' => now(),
]);
$abo = UserAbo::create([
'user_id' => $user->id,
'shopping_user_id' => 0,
'is_for' => 'me',
'email' => $originalEmail,
'payone_userid' => 123456,
'stripe_customer_id' => 'cus_123',
'stripe_payment_method_id' => 'pm_123',
'clearingtype' => 'cc',
'carddata' => ['brand' => 'visa', 'last4' => '4242'],
'active' => false,
'status' => 4,
'abo_interval' => 1,
]);
$business = UserBusiness::create([
'user_id' => $user->id,
'month' => 6,
'year' => 2026,
'email' => $originalEmail,
'first_name' => 'Max',
'last_name' => 'Mustermann',
'user_birthday' => '01.05.1990',
'user_phone' => '49 987654',
'version' => 1,
]);
app(GdprDeletionService::class)->deleteUser($user, null);
$user = User::withTrashed()->find($user->id);
expect($user->deleted_at)->not->toBeNull();
expect($user->email)->not->toContain($originalEmail);
expect($user->email)->toStartWith('delete-');
expect($user->notes)->toBeNull();
expect($user->settings)->toBeNull();
expect($user->payment_methods)->toBeNull();
$account = UserAccount::withTrashed()->find($user->account_id);
expect($account->deleted_at)->not->toBeNull();
expect($account->first_name)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME);
expect($account->last_name)->toBe(GdprDeletionService::ANONYMIZED_LASTNAME);
expect($account->address)->toBeNull();
expect($account->zipcode)->toBeNull();
expect($account->city)->toBeNull();
expect($account->phone)->toBeNull();
expect($account->mobil)->toBeNull();
expect($account->tax_number)->toBeNull();
expect($account->tax_identification_number)->toBeNull();
expect($account->birthday)->toBe('');
expect($account->bank_owner)->toBeNull();
expect($account->bank_iban)->toBeNull();
expect($account->bank_bic)->toBeNull();
expect($account->website)->toBeNull();
expect($account->facebook)->toBeNull();
expect(File::find($file->id))->toBeNull();
Storage::disk('user')->assertMissing('uploads/'.$user->id.'/ausweis.pdf');
expect(UserUpdateEmail::where('user_id', $user->id)->count())->toBe(0);
$abo = UserAbo::withTrashed()->find($abo->id);
expect($abo->email)->toBeNull();
expect($abo->carddata)->toBeNull();
expect((int) $abo->payone_userid)->toBe(0);
expect($abo->stripe_customer_id)->toBeNull();
expect($abo->stripe_payment_method_id)->toBeNull();
$business = UserBusiness::find($business->id);
expect($business->email)->toBeNull();
expect($business->first_name)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME);
expect($business->user_birthday)->toBeNull();
expect($business->user_phone)->toBeNull();
});
it('hängt Downline und Kunden auf den nächsten aktiven Sponsor um', function () {
Mail::fake();
Storage::fake('user');
Storage::fake('public');
$sponsor = gdprConsultant(['payment_account' => now()->addYear()]);
$user = gdprConsultant(['m_sponsor' => $sponsor->id]);
$child = gdprConsultant(['m_sponsor' => $user->id]);
$customer = ShoppingUser::create([
'auth_user_id' => 0,
'member_id' => $user->id,
'billing_email' => 'kunde-'.uniqid('', true).'@example.com',
'billing_country_id' => gdprCountry()->id,
'shipping_country_id' => 1,
]);
app(GdprDeletionService::class)->deleteUser($user, null);
expect($child->fresh()->m_sponsor)->toBe($sponsor->id);
expect($customer->fresh()->member_id)->toBe($sponsor->id);
});
it('blockiert die Löschung bei aktivem Abo', function () {
Mail::fake();
$user = gdprConsultant();
UserAbo::create([
'user_id' => $user->id,
'shopping_user_id' => 0,
'is_for' => 'me',
'email' => $user->email,
'payone_userid' => 111,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 1,
]);
app(GdprDeletionService::class)->deleteUser($user, null);
})->throws(GdprDeletionException::class);
it('blockiert die Löschung von Admin-Accounts', function () {
Mail::fake();
$user = gdprConsultant(['admin' => 2]);
app(GdprDeletionService::class)->deleteUser($user, null);
})->throws(GdprDeletionException::class);
it('anonymisiert einen Endkunden ohne Bestellungen vollständig, inkl. aller Einträge derselben Kundennummer', function () {
Mail::fake();
$country = gdprCountry();
$rowOne = ShoppingUser::create([
'auth_user_id' => 0,
'number' => 4711,
'billing_salutation' => 'Frau',
'billing_firstname' => 'Erika',
'billing_lastname' => 'Musterfrau',
'billing_address' => 'Beispielweg 2',
'billing_zipcode' => '54321',
'billing_city' => 'Beispielstadt',
'billing_phone' => '0123',
'billing_email' => 'erika@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'remarks' => 'Bitte klingeln',
'subscribed' => true,
]);
$rowTwo = ShoppingUser::create([
'auth_user_id' => 0,
'number' => 4711,
'billing_firstname' => 'Erika',
'billing_lastname' => 'Musterfrau',
'billing_email' => 'erika@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
app(GdprDeletionService::class)->deleteShoppingUser($rowOne, null);
foreach ([$rowOne, $rowTwo] as $row) {
$row = $row->fresh();
expect($row->billing_firstname)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME);
expect($row->billing_lastname)->toBe(GdprDeletionService::ANONYMIZED_LASTNAME);
expect($row->billing_address)->toBeNull();
expect($row->billing_zipcode)->toBeNull();
expect($row->billing_city)->toBeNull();
expect($row->billing_phone)->toBeNull();
expect($row->billing_email)->not->toContain('erika@example.com');
expect($row->remarks)->toBe('');
expect($row->subscribed)->toBeFalse();
expect($row->faker_mail)->toBeTrue();
}
});
it('behält bei Endkunden mit Bestellung Name und Rechnungsadresse, entfernt aber alle Kontaktdaten', function () {
Mail::fake();
$country = gdprCountry();
$shopOwner = User::forceCreate([
'email' => 'shop-owner-'.uniqid('', true).'@example.com',
'password' => Hash::make('secret'),
'lang' => 'de',
]);
$userShop = UserShop::create([
'user_id' => $shopOwner->id,
'name' => 'TS'.substr(uniqid('', true), 0, 8),
'slug' => 'ts-'.uniqid(),
'active' => true,
]);
$customer = ShoppingUser::create([
'auth_user_id' => 0,
'number' => 4712,
'billing_firstname' => 'Erika',
'billing_lastname' => 'Musterfrau',
'billing_address' => 'Beispielweg 2',
'billing_zipcode' => '54321',
'billing_city' => 'Beispielstadt',
'billing_phone' => '0123',
'billing_email' => 'erika@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'remarks' => 'Bitte klingeln',
'subscribed' => true,
]);
ShoppingOrder::create([
'shopping_user_id' => $customer->id,
'auth_user_id' => 0,
'country_id' => $country->id,
'user_shop_id' => $userShop->id,
'payment_for' => 6,
'total' => 100,
'subtotal' => 90,
]);
app(GdprDeletionService::class)->deleteShoppingUser($customer, null);
$customer = $customer->fresh();
expect($customer->billing_firstname)->toBe('Erika');
expect($customer->billing_lastname)->toBe('Musterfrau');
expect($customer->billing_address)->toBe('Beispielweg 2');
expect($customer->billing_city)->toBe('Beispielstadt');
expect($customer->billing_phone)->toBeNull();
expect($customer->billing_email)->not->toContain('erika@example.com');
expect($customer->remarks)->toBe('');
expect($customer->subscribed)->toBeFalse();
expect($customer->faker_mail)->toBeTrue();
});
it('lehnt die Endkunden-Löschung für Berater-Datensätze ab', function () {
Mail::fake();
$user = gdprConsultant();
$customer = ShoppingUser::create([
'auth_user_id' => $user->id,
'billing_email' => $user->email,
'billing_country_id' => gdprCountry()->id,
'shipping_country_id' => 1,
]);
app(GdprDeletionService::class)->deleteShoppingUser($customer, null);
})->throws(GdprDeletionException::class);
it('entfernt Zahlungs-Tokens der Abos eines Endkunden', function () {
Mail::fake();
$country = gdprCountry();
$customer = ShoppingUser::create([
'auth_user_id' => 0,
'number' => 4713,
'billing_firstname' => 'Erika',
'billing_lastname' => 'Musterfrau',
'billing_email' => 'erika-abo@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
$abo = UserAbo::create([
'shopping_user_id' => $customer->id,
'is_for' => 'ot',
'email' => 'erika-abo@example.com',
'payone_userid' => 999,
'clearingtype' => 'cc',
'carddata' => ['brand' => 'visa', 'last4' => '4242'],
'active' => false,
'status' => 6,
'abo_interval' => 1,
]);
app(GdprDeletionService::class)->deleteShoppingUser($customer, null);
$abo = UserAbo::withTrashed()->find($abo->id);
expect($abo->email)->toBeNull();
expect($abo->carddata)->toBeNull();
expect((int) $abo->payone_userid)->toBe(0);
});

View file

@ -0,0 +1,134 @@
<?php
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Services\Incentive\IncentiveTracker;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
$makeIncentiveRankingUser = fn () => User::forceCreate([
'email' => 'rank-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
it('vergibt Raenge zuerst an Qualifizierte, dann nach Punkten', function () use ($makeIncentiveRankingUser) {
$incentive = Incentive::factory()->create(['max_winners' => 2]);
$notQualifiedTopPoints = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'is_qualified' => false,
'total_points' => 5000,
]);
$qualifiedMidPoints = IncentiveParticipant::factory()->qualified()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 900,
]);
$qualifiedLowPoints = IncentiveParticipant::factory()->qualified()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 400,
]);
$zeroPoints = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 0,
'rank' => 7,
]);
IncentiveTracker::updateRanking($incentive);
expect($qualifiedMidPoints->refresh()->rank)->toBe(1)
->and($qualifiedLowPoints->refresh()->rank)->toBe(2)
->and($notQualifiedTopPoints->refresh()->rank)->toBe(3)
->and($zeroPoints->refresh()->rank)->toBeNull();
});
it('macht Qualifizierte mit gespeichertem Rang innerhalb max_winners zu Gewinnern', function () use ($makeIncentiveRankingUser) {
$incentive = Incentive::factory()->create(['max_winners' => 2]);
$notQualifiedTopPoints = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'is_qualified' => false,
'total_points' => 5000,
]);
$qualifiedA = IncentiveParticipant::factory()->qualified()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 900,
]);
$qualifiedB = IncentiveParticipant::factory()->qualified()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 400,
]);
IncentiveTracker::updateRanking($incentive);
expect($qualifiedA->refresh()->isWinner())->toBeTrue()
->and($qualifiedB->refresh()->isWinner())->toBeTrue()
->and($notQualifiedTopPoints->refresh()->isWinner())->toBeFalse();
});
it('sortiert bei Punktgleichstand bestaetigte Teilnehmer vor anonymen', function () use ($makeIncentiveRankingUser) {
$incentive = Incentive::factory()->create();
$anonymous = IncentiveParticipant::factory()->unconfirmed()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 500,
]);
$confirmed = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'accepted_terms_at' => now(),
'total_points' => 500,
]);
IncentiveTracker::updateRanking($incentive);
expect($confirmed->refresh()->rank)->toBe(1)
->and($anonymous->refresh()->rank)->toBe(2);
});
it('stimmt mit der Leaderboard-Anzeigereihenfolge ueberein', function () use ($makeIncentiveRankingUser) {
$incentive = Incentive::factory()->create();
IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'is_qualified' => false,
'total_points' => 5000,
]);
IncentiveParticipant::factory()->qualified()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 900,
]);
IncentiveParticipant::factory()->qualified()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 400,
]);
IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'is_qualified' => false,
'total_points' => 200,
]);
IncentiveTracker::updateRanking($incentive);
$displayOrder = IncentiveParticipant::query()
->where('incentive_id', $incentive->id)
->orderByIncentiveLeaderboard()
->pluck('rank')
->all();
expect($displayOrder)->toBe([1, 2, 3, 4]);
});

View file

@ -0,0 +1,151 @@
<?php
use App\Models\UserBusiness;
use App\Services\BusinessPlan\LiveCommissionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-07-22'));
Cache::flush();
});
afterEach(function () {
Carbon::setTestNow();
});
function lcsMakeUser(): User
{
return User::forceCreate([
'email' => 'lcs-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
}
it('liefert die gespeicherte Provision, wenn der Monats-Batch bereits gelaufen ist', function (): void {
$user = lcsMakeUser();
UserBusiness::forceCreate([
'user_id' => $user->id,
'month' => 6,
'year' => 2026,
'version' => 2,
'commission_pp_total' => 120.50,
'commission_shop_sales' => 30.25,
'commission_growth_total' => 10.00,
]);
$commission = app(LiveCommissionService::class)->commission($user, 6, 2026);
expect($commission['total'])->toBe(160.75)
->and($commission['pp'])->toBe(120.5)
->and($commission['shop'])->toBe(30.25)
->and($commission['growth'])->toBe(10.0)
->and($commission['is_live'])->toBeFalse();
});
it('berechnet den Live-Snapshot ueber den gemeinsamen Lauf und cached ihn', function (): void {
$user = lcsMakeUser();
$snapshot = app(LiveCommissionService::class)->snapshot($user, 7, 2026);
expect($snapshot['is_live'])->toBeTrue()
->and($snapshot['failed'])->toBeFalse()
->and($snapshot['payline_points'])->toBe(0.0)
->and($snapshot['paylines'])->toBe(0)
->and(Cache::has("live_business_{$user->id}_7_2026"))->toBeTrue();
});
it('faellt bei fehlschlagender Live-Berechnung auf Null-Werte zurueck und cached den Fehler nicht', function (): void {
$user = lcsMakeUser();
// Deterministischer Fehler: ohne user_levels bricht der Baum-Lauf ab
Schema::drop('user_levels');
$commission = app(LiveCommissionService::class)->commission($user, 7, 2026);
expect($commission['total'])->toBe(0.0)
->and($commission['is_live'])->toBeTrue()
->and(Cache::has("live_business_{$user->id}_7_2026"))->toBeFalse();
});
it('cached die Live-Provision fuer Folgeaufrufe', function (): void {
$user = lcsMakeUser();
Cache::put(
"live_business_{$user->id}_7_2026",
['payline_points' => 12.5, 'paylines' => 2, 'pp' => 99.0, 'shop' => 0.0, 'growth' => 0.0, 'is_live' => true, 'failed' => false],
now()->addHour()
);
$commission = app(LiveCommissionService::class)->commission($user, 7, 2026);
$snapshot = app(LiveCommissionService::class)->snapshot($user, 7, 2026);
expect($commission['total'])->toBe(99.0)
->and($commission['is_live'])->toBeTrue()
->and($snapshot['payline_points'])->toBe(12.5)
->and($snapshot['paylines'])->toBe(2);
});
it('liefert Payline-Punkte und Payline-Tiefe aus dem UserBusiness-Snapshot', function (): void {
$user = lcsMakeUser();
UserBusiness::forceCreate([
'user_id' => $user->id,
'month' => 6,
'year' => 2026,
'version' => 2,
'payline_points' => 350.5,
'qual_user_level' => ['paylines' => 3],
'commission_pp_total' => 10.0,
'commission_shop_sales' => 0.0,
'commission_growth_total' => 0.0,
]);
$snapshot = app(LiveCommissionService::class)->snapshot($user, 6, 2026);
expect($snapshot['payline_points'])->toBe(350.5)
->and($snapshot['paylines'])->toBe(3)
->and($snapshot['is_live'])->toBeFalse();
});
it('rendert die Provisions-Kachel auf der Monatsstatistik-Card', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$html = view('dashboard._statistics', [
'monthlyStats' => [
'month' => 7,
'year' => 2026,
'customer_points' => 100.0,
'team_payline_points' => 400.0,
'payline_is_live' => true,
'payline_forecast' => null,
'commission' => ['total' => 160.75, 'pp' => 120.5, 'shop' => 30.25, 'growth' => 10.0, 'is_live' => true],
'direct_new_partners' => 0,
'team_total_abos' => 0,
'team_partner_abos' => 0,
'team_partner_abos_new' => 0,
'team_customer_abos' => 0,
'team_customer_abos_new' => 0,
'shop_1000' => 0,
'new_partners' => 0,
'own_abos' => 0,
'own_abos_new' => 0,
'own_customer_abos' => 0,
'own_customer_abos_new' => 0,
],
])->render();
expect($html)
->toContain(__('home.commission_today'))
->toContain('160,75 €');
});

View file

@ -0,0 +1,254 @@
<?php
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserBusiness;
use App\Models\UserSalesVolume;
use App\Services\Abo\PaylineForecastService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-07-02'));
Cache::flush();
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
Schema::connection('sqlite')->table('products', function ($table) {
$table->string('slug')->nullable();
});
}
});
afterEach(function () {
Carbon::setTestNow();
});
function pflMakeUser(?int $sponsorId = null): User
{
return User::forceCreate([
'email' => 'pfl-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => now()->addYear(),
]);
}
function pflMakeAbo(int $userId, string $isFor, string $nextDate, int $points, int $status = 2): UserAbo
{
$abo = UserAbo::create([
'user_id' => $userId,
'member_id' => $userId,
'shopping_user_id' => 0,
'email' => 'pfl-abo-'.uniqid('', true).'@example.com',
'is_for' => $isFor,
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => $status === 2,
'status' => $status,
'abo_interval' => (int) Carbon::parse($nextDate)->day,
'start_date' => '2026-01-05',
'last_date' => '2026-06-05',
'next_date' => $nextDate,
]);
$product = Product::forceCreate([
'name' => 'PFL-'.uniqid('', true),
'title' => 'Test',
'price' => 10.0,
'tax' => 19.0,
'active' => true,
'pos' => 1,
'weight' => 100,
'points' => $points,
'slug' => 'pfl-'.uniqid('', true),
]);
UserAboItem::create([
'user_abo_id' => $abo->id,
'product_id' => $product->id,
'comp' => 0,
'qty' => 1,
'status' => 1,
]);
return $abo->fresh();
}
function pflSeedContext(User $user, array $memberIds, int $paylines = 2): void
{
Cache::put(
"payline_context_{$user->id}_".now()->month.'_'.now()->year,
['paylines' => $paylines, 'member_ids' => $memberIds],
now()->addHour()
);
}
it('berechnet die Prognose kumuliert je Ausfuehrungstermin inklusive eigener Abos', function (): void {
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
$fremd = pflMakeUser();
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-05', 20); // Payline-Kundenabo zum 5.
pflMakeAbo($root->id, 'me', '2026-07-10', 30); // eigenes Abo (F12: zaehlt mit)
pflMakeAbo($anna->id, 'me', '2026-07-25', 10); // Payline-Beraterabo zum 25.
pflMakeAbo($fremd->id, 'me', '2026-07-10', 99); // ausserhalb der Payline
pflMakeAbo($anna->id, 'ot', '2026-08-05', 50); // naechster Monat, zaehlt nicht
pflMakeAbo($anna->id, 'ot', '2026-07-20', 40, 3); // angehalten, zaehlt nicht
$forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0);
expect($forecast['base'])->toBe(100.0)
->and($forecast['days'])->toHaveCount(4)
->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false])
->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0, 'total' => 150.0])
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 50.0, 'total' => 150.0])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 60.0, 'total' => 160.0]);
});
it('markiert vergangene Termine und zaehlt nur noch offene Ausfuehrungen', function (): void {
Carbon::setTestNow(Carbon::parse('2026-07-22'));
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'ot', '2026-07-25', 20); // noch offen
pflMakeAbo($anna->id, 'me', '2026-08-05', 30); // bereits fortgeschrieben (naechster Monat)
$forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0);
expect($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'is_past' => true])
->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 0.0, 'is_past' => true])
->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false]);
});
it('liefert Soll und Ist des Payline-Abo-Umsatzes', function (): void {
$root = pflMakeUser();
$anna = pflMakeUser($root->id);
$fremd = pflMakeUser();
pflSeedContext($root, [$anna->id]);
pflMakeAbo($anna->id, 'me', '2026-07-10', 20); // Soll 20
pflMakeAbo($anna->id, 'ot', '2026-07-25', 30); // Soll 30
pflMakeAbo($anna->id, 'ot', '2026-07-25', 40, 4); // gekuendigt, kein Soll
pflMakeAbo($fremd->id, 'me', '2026-07-10', 99); // ausserhalb der Payline
$aboOrder = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => $anna->id,
'country_id' => 0,
'user_shop_id' => 0,
'is_abo' => true,
'paid' => true,
]);
$shopOrder = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => $anna->id,
'country_id' => 0,
'user_shop_id' => 0,
'is_abo' => false,
'paid' => true,
]);
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 25, 'status' => 2]);
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $shopOrder->id, 'month' => 7, 'year' => 2026, 'points' => 50, 'status' => 2]); // kein Abo
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $aboOrder->id, 'month' => 6, 'year' => 2026, 'points' => 60, 'status' => 2]); // anderer Monat
$turnover = app(PaylineForecastService::class)->aboTurnover($root, 7, 2026);
expect($turnover['paylines'])->toBe(2)
->and($turnover['members'])->toBe(1)
->and($turnover['target'])->toBe(50.0)
->and($turnover['actual'])->toBe(25.0)
->and($turnover['team_abo_count'])->toBe(1) // nur Annas Eigenabo, nicht der Fremde
->and($turnover['team_customer_abo_count'])->toBe(1); // gekuendigtes Kundenabo zaehlt nicht
});
it('liefert leere Kennzahlen ohne Payline, Prognose enthaelt aber eigene Abos', function (): void {
$root = pflMakeUser();
pflSeedContext($root, [], 0);
pflMakeAbo($root->id, 'me', '2026-07-10', 30);
$turnover = app(PaylineForecastService::class)->aboTurnover($root, 7, 2026);
$forecast = app(PaylineForecastService::class)->forecastDays($root, 10.0);
expect($turnover['target'])->toBe(0.0)
->and($turnover['actual'])->toBe(0.0)
->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 40.0]);
});
it('ermittelt den Payline-Kontext aus dem UserBusiness-Level und den Linien', function (): void {
$root = pflMakeUser();
$anna = pflMakeUser($root->id); // Linie 1
$ben = pflMakeUser($anna->id); // Linie 2
UserBusiness::forceCreate([
'user_id' => $root->id,
'month' => 7,
'year' => 2026,
'version' => 1,
'qual_user_level' => ['paylines' => 1],
]);
$context = app(PaylineForecastService::class)->paylineContext($root, 7, 2026);
expect($context['paylines'])->toBe(1)
->and($context['member_ids'])->toBe([$anna->id])
->and($context['member_ids'])->not->toContain($ben->id);
});
it('rendert die Payline-Prognose auf der Monatsstatistik-Card', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$html = view('dashboard._statistics', [
'monthlyStats' => [
'month' => 7,
'year' => 2026,
'customer_points' => 100.0,
'team_payline_points' => 400.0,
'payline_is_live' => true,
'payline_forecast' => [
'base' => 500.0,
'days' => [
['day' => 5, 'date' => '05.07.2026', 'upcoming' => 0.0, 'total' => 500.0, 'is_past' => true],
['day' => 10, 'date' => '10.07.2026', 'upcoming' => 30.0, 'total' => 530.0, 'is_past' => false],
['day' => 20, 'date' => '20.07.2026', 'upcoming' => 30.0, 'total' => 530.0, 'is_past' => false],
['day' => 25, 'date' => '25.07.2026', 'upcoming' => 60.0, 'total' => 560.0, 'is_past' => false],
],
],
'direct_new_partners' => 0,
'team_total_abos' => 0,
'team_partner_abos' => 0,
'team_partner_abos_new' => 0,
'team_customer_abos' => 0,
'team_customer_abos_new' => 0,
'shop_1000' => 0,
'new_partners' => 0,
'own_abos' => 0,
'own_abos_new' => 0,
'own_customer_abos' => 0,
'own_customer_abos_new' => 0,
],
])->render();
expect($html)
->toContain(e(__('home.payline_forecast_title')))
->toContain(__('home.forecast_day', ['date' => '25.07.2026']))
->toContain('560')
->toContain(__('home.forecast_done'));
});

View file

@ -1,6 +1,7 @@
<?php
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAboOrder;
@ -155,6 +156,46 @@ it('zählt nur aktive Berater und blendet inaktive/fehlerhafte aus', function ()
->and($overview['totals']['consultants'])->toBe(1);
});
it('liefert je Ebene abgerechnete Abo-Punkte und die Prognose bis zum letzten Liefertag', function (): void {
Carbon\Carbon::setTestNow('2026-06-10');
$root = makeUser();
$anna = makeUser($root->id);
// Offene Ausfuehrung bis zum 25.: zaehlt in der Prognose
makeAbo($anna->id, $anna->id, 'me', '2026-05-05', 50)->update(['next_date' => '2026-06-20']);
// Naechste Ausfuehrung erst im Folgemonat: zaehlt nicht
makeAbo($anna->id, $anna->id, 'ot', '2026-05-10', 30)->update(['next_date' => '2026-07-05']);
// Bereits abgerechnete Abo-Punkte im Juni
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0,
'auth_user_id' => $anna->id,
'country_id' => 0,
'user_shop_id' => 0,
'is_abo' => true,
'paid' => true,
]);
UserSalesVolume::forceCreate([
'user_id' => $anna->id,
'shopping_order_id' => $order->id,
'month' => 6,
'year' => 2026,
'points' => 30,
'status' => 2,
]);
$overview = app(TeamAboOverviewService::class)->linesOverview($root, 6, 2026);
$line1 = $overview['lines'][1];
expect($line1['abo_points_billed'])->toBe(30.0)
->and($line1['abo_points_expected'])->toBe(80.0) // 30 abgerechnet + 50 offen bis 20.06.
->and($overview['totals']['abo_points_billed'])->toBe(30.0)
->and($overview['totals']['abo_points_expected'])->toBe(80.0);
Carbon\Carbon::setTestNow();
});
it('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void {
$root = makeUser();
$anna = makeUser($root->id);

View file

@ -0,0 +1,112 @@
<?php
use App\Models\UserAbo;
use App\Services\BusinessPlan\TreeHtmlRenderer;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function tsabMakeUser(): User
{
return User::forceCreate([
'email' => 'tsab-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
}
/**
* @param array<string, mixed> $overrides
*/
function tsabMakeAbo(array $overrides = []): UserAbo
{
return UserAbo::create(array_merge([
'user_id' => 0,
'member_id' => null,
'shopping_user_id' => 0,
'email' => 'tsab-abo-'.uniqid('', true).'@example.com',
'is_for' => 'me',
'payone_userid' => 0,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
], $overrides));
}
it('renders own-abo and customer-abo badges for a team partner', function () {
$partner = tsabMakeUser();
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'me']);
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'ot', 'member_id' => $partner->id]);
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'ot', 'member_id' => $partner->id]);
$renderer = new TreeHtmlRenderer('member');
$renderer->loadAboBadges([$partner->id]);
$html = $renderer->renderAboBadges($partner->id);
expect($html)->toContain('badge badge-success')
->toContain('>A</span>')
->toContain(e(__('team.badge_own_abo')))
->toContain('badge badge-danger')
->toContain('>(2)</span>')
->toContain(e(trans_choice('team.badge_customer_abos', 2)));
});
it('renders no badges for a team partner without active abos', function () {
$partner = tsabMakeUser();
$renderer = new TreeHtmlRenderer('member');
$renderer->loadAboBadges([$partner->id]);
expect($renderer->renderAboBadges($partner->id))->toBe('');
});
it('does not count soft-deleted, inactive or terminated abos', function () {
$partner = tsabMakeUser();
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'me'])->delete();
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'ot', 'member_id' => $partner->id])->delete();
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'ot', 'member_id' => $partner->id, 'active' => false]);
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'ot', 'member_id' => $partner->id, 'status' => 4]);
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'ot', 'member_id' => $partner->id, 'status' => 5]);
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'ot', 'member_id' => $partner->id, 'status' => 6]);
$renderer = new TreeHtmlRenderer('member');
$renderer->loadAboBadges([$partner->id]);
expect($renderer->renderAboBadges($partner->id))->toBe('');
});
it('shows only the customer badge when the partner has customer abos but no own abo', function () {
$partner = tsabMakeUser();
tsabMakeAbo(['user_id' => $partner->id, 'is_for' => 'ot', 'member_id' => $partner->id]);
$renderer = new TreeHtmlRenderer('member');
$renderer->loadAboBadges([$partner->id]);
$html = $renderer->renderAboBadges($partner->id);
expect($html)->not->toContain('badge badge-success')
->toContain('badge badge-danger')
->toContain('>(1)</span>')
->toContain(e(trans_choice('team.badge_customer_abos', 1)));
});
it('does not attribute abos of other users to the team partner', function () {
$partner = tsabMakeUser();
$other = tsabMakeUser();
tsabMakeAbo(['user_id' => $other->id, 'is_for' => 'me']);
tsabMakeAbo(['user_id' => $other->id, 'is_for' => 'ot', 'member_id' => $other->id]);
$renderer = new TreeHtmlRenderer('member');
$renderer->loadAboBadges([$partner->id, $other->id]);
expect($renderer->renderAboBadges($partner->id))->toBe('')
->and($renderer->renderAboBadges($other->id))->toContain('>A</span>')
->and($renderer->renderAboBadges($other->id))->toContain('>(1)</span>');
});

View file

@ -0,0 +1,63 @@
<?php
use App\Models\Country;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShippingPrice;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Versandland DE mit einer Stufe: 19,90 € brutto bei 19 % MwSt (netto 16,72 €, Steueranteil 3,18 €).
*/
function makeShippingEnvForYardTaxTest(): ShippingCountry
{
$country = Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
$shipping = Shipping::create(['name' => 'Standard', 'active' => true]);
ShippingPrice::create([
'shipping_id' => $shipping->id, 'price' => 19.90, 'price_comp' => 19.90, 'num_comp' => 0,
'tax_rate' => 19, 'weight_from' => 1, 'weight_to' => 31500,
]);
return ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
}
describe('Yard Versandkosten-Steuer', function () {
beforeEach(function () {
$this->shippingCountry = makeShippingEnvForYardTaxTest();
});
it('berechnet den Netto-Versand und den Steueranteil (brutto minus netto)', function () {
$yard = Yard::instance('test-shipping-tax');
$yard->destroy();
$yard->setShippingCountryWithPrice($this->shippingCountry->id);
$yard->add(1, 'A', 1, 10.0, false, false, ['weight' => 500]);
$yard->reCalculateShippingPrice();
expect((float) $yard->getShippingPrice())->toBe(19.90)
->and($yard->shippingNet(2, '.', ''))->toBe('16.72')
->and((float) $yard->getYardExtra('shipping_tax'))->toBe(3.18);
$yard->destroy();
});
it('setzt den Steueranteil bei kostenlosem Versand auf 0', function () {
$yard = Yard::instance('test-shipping-tax');
$yard->destroy();
$yard->setShippingCountryWithPrice($this->shippingCountry->id);
$yard->reCalculateShippingPrice();
expect((float) $yard->getShippingPrice())->toBe(0.0)
->and((float) $yard->getYardExtra('shipping_tax'))->toBe(0.0);
$yard->destroy();
});
});