- |
+ |
@@ -129,7 +173,7 @@
@empty
- | {{ __('tables.no_data_available') }} |
+ {{ __('tables.no_data_available') }} |
@endforelse
@@ -143,6 +187,8 @@
+{{ $totals['own_abos_new'] + $totals['customer_abos_new'] }} |
{{ $totals['total_abos'] }} |
{{ \App\Services\Util::formatNumber($totals['own_abo_points'] + $totals['customer_abo_points']) }} |
+ {{ \App\Services\Util::formatNumber($totals['abo_points_billed'] ?? 0) }} |
+ {{ \App\Services\Util::formatNumber($totals['abo_points_expected'] ?? 0) }} |
@endif
diff --git a/resources/views/web/templates/card.blade.php b/resources/views/web/templates/card.blade.php
index 7d5330ff..9fc7a077 100644
--- a/resources/views/web/templates/card.blade.php
+++ b/resources/views/web/templates/card.blade.php
@@ -337,9 +337,10 @@
--}}
+ @php($cardShippingTaxFree = Yard::instance($yard_instance)->getUserTaxFree())
@if(Yard::instance($yard_instance)->getUserTaxFree())
diff --git a/resources/views/web/templates/checkout.blade.php b/resources/views/web/templates/checkout.blade.php
index a882f68f..adbc0408 100644
--- a/resources/views/web/templates/checkout.blade.php
+++ b/resources/views/web/templates/checkout.blade.php
@@ -339,9 +339,10 @@
{{ __('Delivery country') }}: |
{{ Yard::instance($yard_instance)->getShippingCountryName() }} |
+ @php($checkoutShippingTaxFree = Yard::instance($yard_instance)->getUserTaxFree())
- | {{ __('order.shipping_costs') }}: |
- {{ Yard::instance($yard_instance)->shipping() }} € |
+ {{ __('order.shipping_costs') }} ({{ $checkoutShippingTaxFree ? __('order.net') : __('order.incl_VAT') }}): |
+ {{ $checkoutShippingTaxFree ? Yard::instance($yard_instance)->shippingNet() : Yard::instance($yard_instance)->shipping() }} € |
@if(Yard::instance($yard_instance)->getUserTaxFree())
diff --git a/routes/domains/crm.php b/routes/domains/crm.php
index f0c6dbd9..fa7d825e 100644
--- a/routes/domains/crm.php
+++ b/routes/domains/crm.php
@@ -334,6 +334,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/admin/customers', 'CustomerController@index')->name('admin_customers');
Route::get('/admin/customer/detail/{id}', 'CustomerController@detail')->name('admin_customer_detail');
Route::get('/admin/customer/delete/{id}', 'CustomerController@delete')->name('admin_customer_delete');
+ Route::post('/admin/customer/gdpr-delete/{id}', 'CustomerController@gdprDelete')->name('admin_customer_gdpr_delete');
Route::get('/admin/customer/edit/{id}', 'CustomerController@edit')->name('admin_customer_edit');
Route::post('/admin/customer/edit/{id}', 'CustomerController@store')->name('admin_customer_edit');
Route::get('/admin/customer/datatable', 'CustomerController@getCustomers')->name('admin_customer_datatable');
diff --git a/tests/Feature/AboAdminDeleteTest.php b/tests/Feature/AboAdminDeleteTest.php
index 1fc1fc0a..cdd45bca 100644
--- a/tests/Feature/AboAdminDeleteTest.php
+++ b/tests/Feature/AboAdminDeleteTest.php
@@ -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());
diff --git a/tests/Feature/AboOneAboRuleTest.php b/tests/Feature/AboOneAboRuleTest.php
new file mode 100644
index 00000000..93a37c01
--- /dev/null
+++ b/tests/Feature/AboOneAboRuleTest.php
@@ -0,0 +1,94 @@
+ '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);
+});
diff --git a/tests/Feature/AboOneTimeViewTest.php b/tests/Feature/AboOneTimeViewTest.php
index e61a7067..0796848b 100644
--- a/tests/Feature/AboOneTimeViewTest.php
+++ b/tests/Feature/AboOneTimeViewTest.php
@@ -1,10 +1,12 @@
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');
diff --git a/tests/Feature/AboPauseTest.php b/tests/Feature/AboPauseTest.php
new file mode 100644
index 00000000..c4ee4084
--- /dev/null
+++ b/tests/Feature/AboPauseTest.php
@@ -0,0 +1,227 @@
+ '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'));
+});
diff --git a/tests/Feature/AboUpdateAuthorizationTest.php b/tests/Feature/AboUpdateAuthorizationTest.php
new file mode 100644
index 00000000..24f5c515
--- /dev/null
+++ b/tests/Feature/AboUpdateAuthorizationTest.php
@@ -0,0 +1,223 @@
+ '/'.$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 $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');
+});
diff --git a/tests/Feature/AdminAboDatatableSortTest.php b/tests/Feature/AdminAboDatatableSortTest.php
new file mode 100644
index 00000000..b56ec70c
--- /dev/null
+++ b/tests/Feature/AdminAboDatatableSortTest.php
@@ -0,0 +1,81 @@
+ '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);
+});
diff --git a/tests/Feature/ApiOrderFreeShippingTest.php b/tests/Feature/ApiOrderFreeShippingTest.php
new file mode 100644
index 00000000..d5b41ba9
--- /dev/null
+++ b/tests/Feature/ApiOrderFreeShippingTest.php
@@ -0,0 +1,156 @@
+ '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);
+ });
+});
diff --git a/tests/Feature/BackofficeStatisticsAccessTest.php b/tests/Feature/BackofficeStatisticsAccessTest.php
index 7dd035fe..b4574d26 100644
--- a/tests/Feature/BackofficeStatisticsAccessTest.php
+++ b/tests/Feature/BackofficeStatisticsAccessTest.php
@@ -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,
diff --git a/tests/Feature/BackofficeStatisticsDrilldownTest.php b/tests/Feature/BackofficeStatisticsDrilldownTest.php
index cffac0fc..6bc692b2 100644
--- a/tests/Feature/BackofficeStatisticsDrilldownTest.php
+++ b/tests/Feature/BackofficeStatisticsDrilldownTest.php
@@ -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);
+});
diff --git a/tests/Feature/BackofficeStatisticsListsTest.php b/tests/Feature/BackofficeStatisticsListsTest.php
index 90048570..7138a14e 100644
--- a/tests/Feature/BackofficeStatisticsListsTest.php
+++ b/tests/Feature/BackofficeStatisticsListsTest.php
@@ -1,7 +1,11 @@
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);
+});
diff --git a/tests/Feature/BusinessStoreOptimized/AssignMonthlyQualKpBonusPointsTest.php b/tests/Feature/BusinessStoreOptimized/AssignMonthlyQualKpBonusPointsTest.php
index c5e71f83..561cf072 100644
--- a/tests/Feature/BusinessStoreOptimized/AssignMonthlyQualKpBonusPointsTest.php
+++ b/tests/Feature/BusinessStoreOptimized/AssignMonthlyQualKpBonusPointsTest.php
@@ -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);
diff --git a/tests/Feature/CheckoutVatIdRevalidationTest.php b/tests/Feature/CheckoutVatIdRevalidationTest.php
new file mode 100644
index 00000000..c8338df0
--- /dev/null
+++ b/tests/Feature/CheckoutVatIdRevalidationTest.php
@@ -0,0 +1,147 @@
+ '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();
+});
diff --git a/tests/Feature/DeleteAllKasSubdomainsCommandTest.php b/tests/Feature/DeleteAllKasSubdomainsCommandTest.php
deleted file mode 100644
index 57586286..00000000
--- a/tests/Feature/DeleteAllKasSubdomainsCommandTest.php
+++ /dev/null
@@ -1,109 +0,0 @@
-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);
-});
diff --git a/tests/Feature/DeletePartnerShopSubdomainsCommandTest.php b/tests/Feature/DeletePartnerShopSubdomainsCommandTest.php
deleted file mode 100644
index 72cbbf29..00000000
--- a/tests/Feature/DeletePartnerShopSubdomainsCommandTest.php
+++ /dev/null
@@ -1,113 +0,0 @@
-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();
-});
diff --git a/tests/Feature/GdprDeletionTest.php b/tests/Feature/GdprDeletionTest.php
new file mode 100644
index 00000000..061e3d2d
--- /dev/null
+++ b/tests/Feature/GdprDeletionTest.php
@@ -0,0 +1,371 @@
+ '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);
+});
diff --git a/tests/Feature/Incentive/IncentiveRankingTest.php b/tests/Feature/Incentive/IncentiveRankingTest.php
new file mode 100644
index 00000000..8ea69241
--- /dev/null
+++ b/tests/Feature/Incentive/IncentiveRankingTest.php
@@ -0,0 +1,134 @@
+ 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]);
+});
diff --git a/tests/Feature/LiveCommissionServiceTest.php b/tests/Feature/LiveCommissionServiceTest.php
new file mode 100644
index 00000000..29228acf
--- /dev/null
+++ b/tests/Feature/LiveCommissionServiceTest.php
@@ -0,0 +1,151 @@
+ '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 €');
+});
diff --git a/tests/Feature/PaylineForecastServiceTest.php b/tests/Feature/PaylineForecastServiceTest.php
new file mode 100644
index 00000000..e150e977
--- /dev/null
+++ b/tests/Feature/PaylineForecastServiceTest.php
@@ -0,0 +1,254 @@
+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'));
+});
diff --git a/tests/Feature/TeamAboOverviewTest.php b/tests/Feature/TeamAboOverviewTest.php
index cfc093d4..c5aa90e8 100644
--- a/tests/Feature/TeamAboOverviewTest.php
+++ b/tests/Feature/TeamAboOverviewTest.php
@@ -1,6 +1,7 @@
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);
diff --git a/tests/Feature/TeamStructureAboBadgesTest.php b/tests/Feature/TeamStructureAboBadgesTest.php
new file mode 100644
index 00000000..344869e6
--- /dev/null
+++ b/tests/Feature/TeamStructureAboBadgesTest.php
@@ -0,0 +1,112 @@
+ 'tsab-'.uniqid('', true).'@example.com',
+ 'password' => bcrypt('secret'),
+ 'lang' => 'de',
+ ]);
+}
+
+/**
+ * @param array $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')
+ ->toContain(e(__('team.badge_own_abo')))
+ ->toContain('badge badge-danger')
+ ->toContain('>(2)')
+ ->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)')
+ ->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')
+ ->and($renderer->renderAboBadges($other->id))->toContain('>(1)');
+});
diff --git a/tests/Feature/YardShippingTaxTest.php b/tests/Feature/YardShippingTaxTest.php
new file mode 100644
index 00000000..94f7377a
--- /dev/null
+++ b/tests/Feature/YardShippingTaxTest.php
@@ -0,0 +1,63 @@
+ '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();
+ });
+});
diff --git a/tests/Unit/Controllers/SiteControllerTemplateGuardTest.php b/tests/Unit/Controllers/SiteControllerTemplateGuardTest.php
new file mode 100644
index 00000000..15b7f3e2
--- /dev/null
+++ b/tests/Unit/Controllers/SiteControllerTemplateGuardTest.php
@@ -0,0 +1,43 @@
+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',
+]);
diff --git a/tests/Unit/Incentive/IncentiveTrackerTest.php b/tests/Unit/Incentive/IncentiveTrackerTest.php
index e944612c..379cded3 100644
--- a/tests/Unit/Incentive/IncentiveTrackerTest.php
+++ b/tests/Unit/Incentive/IncentiveTrackerTest.php
@@ -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,
]);
diff --git a/tests/Unit/Services/BackofficeDashboardServiceTest.php b/tests/Unit/Services/BackofficeDashboardServiceTest.php
index cfe6cefd..acb134b7 100644
--- a/tests/Unit/Services/BackofficeDashboardServiceTest.php
+++ b/tests/Unit/Services/BackofficeDashboardServiceTest.php
@@ -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');
diff --git a/tests/Unit/Services/DatevExportServiceTest.php b/tests/Unit/Services/DatevExportServiceTest.php
index 6eaae9d1..ab506b76 100644
--- a/tests/Unit/Services/DatevExportServiceTest.php
+++ b/tests/Unit/Services/DatevExportServiceTest.php
@@ -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()
{
diff --git a/tests/Unit/Services/VatIdServiceTest.php b/tests/Unit/Services/VatIdServiceTest.php
new file mode 100644
index 00000000..21081873
--- /dev/null
+++ b/tests/Unit/Services/VatIdServiceTest.php
@@ -0,0 +1,75 @@
+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'));
+ }
+}
|