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:
parent
c06740b9be
commit
d4e9d108ad
137 changed files with 7610 additions and 2955 deletions
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
94
tests/Feature/AboOneAboRuleTest.php
Normal file
94
tests/Feature/AboOneAboRuleTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
227
tests/Feature/AboPauseTest.php
Normal file
227
tests/Feature/AboPauseTest.php
Normal 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'));
|
||||
});
|
||||
223
tests/Feature/AboUpdateAuthorizationTest.php
Normal file
223
tests/Feature/AboUpdateAuthorizationTest.php
Normal 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');
|
||||
});
|
||||
81
tests/Feature/AdminAboDatatableSortTest.php
Normal file
81
tests/Feature/AdminAboDatatableSortTest.php
Normal 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);
|
||||
});
|
||||
156
tests/Feature/ApiOrderFreeShippingTest.php
Normal file
156
tests/Feature/ApiOrderFreeShippingTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
147
tests/Feature/CheckoutVatIdRevalidationTest.php
Normal file
147
tests/Feature/CheckoutVatIdRevalidationTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
371
tests/Feature/GdprDeletionTest.php
Normal file
371
tests/Feature/GdprDeletionTest.php
Normal 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);
|
||||
});
|
||||
134
tests/Feature/Incentive/IncentiveRankingTest.php
Normal file
134
tests/Feature/Incentive/IncentiveRankingTest.php
Normal 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]);
|
||||
});
|
||||
151
tests/Feature/LiveCommissionServiceTest.php
Normal file
151
tests/Feature/LiveCommissionServiceTest.php
Normal 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 €');
|
||||
});
|
||||
254
tests/Feature/PaylineForecastServiceTest.php
Normal file
254
tests/Feature/PaylineForecastServiceTest.php
Normal 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'));
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
112
tests/Feature/TeamStructureAboBadgesTest.php
Normal file
112
tests/Feature/TeamStructureAboBadgesTest.php
Normal 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>');
|
||||
});
|
||||
63
tests/Feature/YardShippingTaxTest.php
Normal file
63
tests/Feature/YardShippingTaxTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
43
tests/Unit/Controllers/SiteControllerTemplateGuardTest.php
Normal file
43
tests/Unit/Controllers/SiteControllerTemplateGuardTest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Web\SiteController;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
function isRenderableTemplate(string $template): bool
|
||||
{
|
||||
$controller = new SiteController;
|
||||
$method = new ReflectionMethod($controller, 'isRenderableTemplate');
|
||||
|
||||
return $method->invoke($controller, $template);
|
||||
}
|
||||
|
||||
it('blocks controller-backed templates in the catch-all route', function (string $template) {
|
||||
expect(isRenderableTemplate($template))->toBeFalse();
|
||||
})->with([
|
||||
'checkout',
|
||||
'checkout-final',
|
||||
'checkout-is-final',
|
||||
'checkout-error',
|
||||
'card',
|
||||
'kontakt',
|
||||
'registrierung',
|
||||
'widerruf',
|
||||
]);
|
||||
|
||||
it('blocks blade partials starting with an underscore', function () {
|
||||
expect(isRenderableTemplate('_categories'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('blocks templates that do not exist', function () {
|
||||
expect(isRenderableTemplate('does-not-exist'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows plain content templates', function (string $template) {
|
||||
expect(isRenderableTemplate($template))->toBeTrue();
|
||||
})->with([
|
||||
'aloevera',
|
||||
'karrierechancen',
|
||||
'vorteile',
|
||||
]);
|
||||
|
|
@ -3,34 +3,37 @@
|
|||
use App\Models\Incentive;
|
||||
use App\Models\IncentiveParticipant;
|
||||
use App\Models\IncentivePointsLog;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
|
||||
// === Ranking Logic ===
|
||||
|
||||
it('ranking logic assigns ranks by total_points descending', function () {
|
||||
$p1 = new IncentiveParticipant(['total_points' => 1000]);
|
||||
$p2 = new IncentiveParticipant(['total_points' => 3000]);
|
||||
$p3 = new IncentiveParticipant(['total_points' => 2000]);
|
||||
it('ranking logic assigns ranks qualified first, then by total_points descending', function () {
|
||||
$p1 = new IncentiveParticipant(['total_points' => 3000, 'is_qualified' => false]);
|
||||
$p2 = new IncentiveParticipant(['total_points' => 1000, 'is_qualified' => true]);
|
||||
$p3 = new IncentiveParticipant(['total_points' => 2000, 'is_qualified' => true]);
|
||||
|
||||
// Simulate the ranking logic from IncentiveTracker::updateRanking
|
||||
$participants = collect([$p1, $p2, $p3])->sortByDesc('total_points')->values();
|
||||
$participants = collect([$p1, $p2, $p3])
|
||||
->sortByDesc(fn (IncentiveParticipant $p) => [$p->is_qualified, $p->total_points])
|
||||
->values();
|
||||
$rank = 1;
|
||||
foreach ($participants as $p) {
|
||||
$p->rank = $rank;
|
||||
$rank++;
|
||||
}
|
||||
|
||||
expect($p2->rank)->toBe(1)
|
||||
->and($p3->rank)->toBe(2)
|
||||
expect($p3->rank)->toBe(1)
|
||||
->and($p2->rank)->toBe(2)
|
||||
->and($p1->rank)->toBe(3);
|
||||
});
|
||||
|
||||
// === Determine Log Type ===
|
||||
|
||||
it('determines abo type for shop orders', function () {
|
||||
$method = new ReflectionMethod(IncentiveTracker::class, 'determineLogType');
|
||||
$method = new ReflectionMethod(IncentiveParticipant::class, 'determineLogType');
|
||||
|
||||
$usv = new \App\Models\UserSalesVolume([
|
||||
$usv = new UserSalesVolume([
|
||||
'status_turnover' => 2,
|
||||
'status_points' => 1,
|
||||
]);
|
||||
|
|
@ -39,9 +42,9 @@ it('determines abo type for shop orders', function () {
|
|||
});
|
||||
|
||||
it('determines abo type for customer-only points', function () {
|
||||
$method = new ReflectionMethod(IncentiveTracker::class, 'determineLogType');
|
||||
$method = new ReflectionMethod(IncentiveParticipant::class, 'determineLogType');
|
||||
|
||||
$usv = new \App\Models\UserSalesVolume([
|
||||
$usv = new UserSalesVolume([
|
||||
'status_turnover' => 1,
|
||||
'status_points' => 2,
|
||||
]);
|
||||
|
|
@ -50,9 +53,9 @@ it('determines abo type for customer-only points', function () {
|
|||
});
|
||||
|
||||
it('determines partner type for advisor orders', function () {
|
||||
$method = new ReflectionMethod(IncentiveTracker::class, 'determineLogType');
|
||||
$method = new ReflectionMethod(IncentiveParticipant::class, 'determineLogType');
|
||||
|
||||
$usv = new \App\Models\UserSalesVolume([
|
||||
$usv = new UserSalesVolume([
|
||||
'status_turnover' => 1,
|
||||
'status_points' => 1,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ beforeEach(function () {
|
|||
Schema::dropIfExists('backoffice_statistics_snapshots');
|
||||
Schema::dropIfExists('user_abo_orders');
|
||||
Schema::dropIfExists('user_abo_items');
|
||||
Schema::dropIfExists('products');
|
||||
Schema::dropIfExists('user_abos');
|
||||
Schema::dropIfExists('payment_transactions');
|
||||
Schema::dropIfExists('shopping_payments');
|
||||
|
|
@ -80,6 +81,13 @@ beforeEach(function () {
|
|||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('products', function ($table) {
|
||||
$table->increments('id');
|
||||
$table->decimal('points', 8, 2)->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('user_abo_orders', function ($table) {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_abo_id');
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class DatevExportServiceTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function it_maps_eu_with_ustid_to_account_8125_bu_1()
|
||||
public function it_maps_eu_sold_tax_free_to_account_8125_bu_1()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount');
|
||||
$method->setAccessible(true);
|
||||
|
|
@ -72,7 +72,7 @@ class DatevExportServiceTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function it_maps_eu_without_ustid_to_domestic_19_percent()
|
||||
public function it_maps_eu_sold_with_tax_to_domestic_19_percent()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount');
|
||||
$method->setAccessible(true);
|
||||
|
|
@ -84,7 +84,7 @@ class DatevExportServiceTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function it_maps_eu_without_ustid_7_percent_to_domestic_7()
|
||||
public function it_maps_eu_sold_with_tax_7_percent_to_domestic_7()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount');
|
||||
$method->setAccessible(true);
|
||||
|
|
@ -184,6 +184,71 @@ class DatevExportServiceTest extends TestCase
|
|||
$this->assertEquals('normal', $result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function credit_taxable_snapshot_takes_priority_over_current_account()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'determineCommissionTaxStatus');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Account ist heute USt-pflichtig, aber die Gutschrift wurde als
|
||||
// Kleinunternehmer ausgestellt -> Beleg zählt
|
||||
$account = new \stdClass;
|
||||
$account->reverse_charge = false;
|
||||
$account->taxable_sales = 1;
|
||||
|
||||
$credit = new \stdClass;
|
||||
$credit->taxable = 2;
|
||||
|
||||
$this->assertEquals('kleinunternehmer', $method->invoke($this->service, $account, $credit));
|
||||
|
||||
// Umgekehrt: Account heute Kleinunternehmer, Beleg mit USt ausgestellt
|
||||
$account->taxable_sales = 2;
|
||||
$credit->taxable = 1;
|
||||
|
||||
$this->assertEquals('normal', $method->invoke($this->service, $account, $credit));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function credit_taxable_3_maps_to_reverse_charge_for_eu_account()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'determineCommissionTaxStatus');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$country = new \stdClass;
|
||||
$country->eu_country = true;
|
||||
|
||||
$account = new \stdClass;
|
||||
$account->reverse_charge = false;
|
||||
$account->taxable_sales = 1;
|
||||
$account->country = $country;
|
||||
|
||||
$credit = new \stdClass;
|
||||
$credit->taxable = 3;
|
||||
|
||||
$this->assertEquals('reverse_charge', $method->invoke($this->service, $account, $credit));
|
||||
|
||||
// Drittland -> Kleinunternehmer-Fallback (kein BU-Schlüssel nötig)
|
||||
$country->eu_country = false;
|
||||
|
||||
$this->assertEquals('kleinunternehmer', $method->invoke($this->service, $account, $credit));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function credit_without_taxable_snapshot_falls_back_to_account()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'determineCommissionTaxStatus');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$account = new \stdClass;
|
||||
$account->reverse_charge = false;
|
||||
$account->taxable_sales = 2;
|
||||
|
||||
$credit = new \stdClass;
|
||||
$credit->taxable = 0;
|
||||
|
||||
$this->assertEquals('kleinunternehmer', $method->invoke($this->service, $account, $credit));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CSV Rendering Tests
|
||||
|
|
@ -446,6 +511,31 @@ class DatevExportServiceTest extends TestCase
|
|||
$this->assertEquals(5.00, $method->invoke($this->service, '5,00'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_parses_numbers_with_thousands_separators()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'parseNumber');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// US-Format mit Tausendertrenner (so speichert net_split Beträge >= 1000)
|
||||
$this->assertEquals(1434.70, $method->invoke($this->service, '1,434.70'));
|
||||
$this->assertEquals(1044.69, $method->invoke($this->service, '1,044.69'));
|
||||
$this->assertEquals(1234567.89, $method->invoke($this->service, '1,234,567.89'));
|
||||
|
||||
// Deutsches Format mit Tausendertrenner
|
||||
$this->assertEquals(1434.70, $method->invoke($this->service, '1.434,70'));
|
||||
|
||||
// Nur Tausendertrenner ohne Dezimalstellen
|
||||
$this->assertEquals(1434.0, $method->invoke($this->service, '1,434'));
|
||||
|
||||
// Negative Werte
|
||||
$this->assertEquals(-1234.56, $method->invoke($this->service, '-1,234.56'));
|
||||
|
||||
// Numerische Eingaben und Leerstring
|
||||
$this->assertEquals(12.5, $method->invoke($this->service, 12.5));
|
||||
$this->assertEquals(0.0, $method->invoke($this->service, ''));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_parses_homeparty_tax_split_format()
|
||||
{
|
||||
|
|
@ -599,6 +689,46 @@ class DatevExportServiceTest extends TestCase
|
|||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_german_ustid_for_eu_orders()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'resolveEuUstid');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$account = new \stdClass;
|
||||
$account->tax_identification_number = 'DE123456789';
|
||||
|
||||
$user = new \stdClass;
|
||||
$user->account = $account;
|
||||
|
||||
$order = new \stdClass;
|
||||
$order->auth_user = $user;
|
||||
|
||||
$result = $method->invoke($this->service, $order, false);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_implausible_ustid_for_eu_orders()
|
||||
{
|
||||
$method = new \ReflectionMethod(DatevExportService::class, 'resolveEuUstid');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$account = new \stdClass;
|
||||
$account->tax_identification_number = '53618009';
|
||||
|
||||
$user = new \stdClass;
|
||||
$user->account = $account;
|
||||
|
||||
$order = new \stdClass;
|
||||
$order->auth_user = $user;
|
||||
|
||||
$result = $method->invoke($this->service, $order, false);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_returns_null_ustid_when_account_has_no_tax_id()
|
||||
{
|
||||
|
|
|
|||
75
tests/Unit/Services/VatIdServiceTest.php
Normal file
75
tests/Unit/Services/VatIdServiceTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\VatIdService;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VatIdServiceTest extends TestCase
|
||||
{
|
||||
private VatIdService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new VatIdService;
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_normalizes_vat_ids()
|
||||
{
|
||||
$this->assertEquals('ATU12345678', $this->service->normalize(' at-u 12.345,678 '));
|
||||
$this->assertEquals('', $this->service->normalize(null));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_extracts_country_code_from_prefix()
|
||||
{
|
||||
$this->assertEquals('AT', $this->service->extractCountryCode('ATU12345678'));
|
||||
$this->assertEquals('ES', $this->service->extractCountryCode('ESY9284953M'));
|
||||
|
||||
// Kein EU-Präfix -> Fallback auf Account-Land
|
||||
$this->assertEquals('AT', $this->service->extractCountryCode('53618009', 'AT'));
|
||||
$this->assertNull($this->service->extractCountryCode('53618009', 'CH'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_checks_plausible_format()
|
||||
{
|
||||
$this->assertTrue($this->service->isPlausibleFormat('ATU12345678'));
|
||||
$this->assertTrue($this->service->isPlausibleFormat('DE123456789'));
|
||||
$this->assertTrue($this->service->isPlausibleFormat('ESY9284953M'));
|
||||
|
||||
// Nur Ziffern ohne Länderpräfix (z.B. österreichische Steuernummer)
|
||||
$this->assertFalse($this->service->isPlausibleFormat('53618009'));
|
||||
// Kein EU-Ländercode
|
||||
$this->assertFalse($this->service->isPlausibleFormat('CH123456789'));
|
||||
$this->assertFalse($this->service->isPlausibleFormat(''));
|
||||
$this->assertFalse($this->service->isPlausibleFormat(null));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function german_vat_ids_never_qualify_for_reverse_charge()
|
||||
{
|
||||
$this->assertFalse($this->service->qualifiesForReverseCharge('DE123456789'));
|
||||
$this->assertFalse($this->service->qualifiesForReverseCharge('de 123 456 789'));
|
||||
$this->assertTrue($this->service->qualifiesForReverseCharge('ATU12345678'));
|
||||
$this->assertFalse($this->service->qualifiesForReverseCharge('53618009'));
|
||||
$this->assertFalse($this->service->qualifiesForReverseCharge(null));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function vies_check_returns_invalid_for_implausible_ids_without_api_call()
|
||||
{
|
||||
$this->assertEquals(VatIdService::CHECK_INVALID, $this->service->checkVies('53618009'));
|
||||
$this->assertEquals(VatIdService::CHECK_INVALID, $this->service->checkViesCached(''));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function vies_check_returns_unavailable_when_disabled()
|
||||
{
|
||||
config(['services.vies.enabled' => false]);
|
||||
|
||||
$this->assertEquals(VatIdService::CHECK_UNAVAILABLE, $this->service->checkVies('ATU12345678'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue