Abo Einmalprodukte: Phasen 5-7, konfigurierbare Sperrfristen, Lokalisierung & Dashboard

Rechnung/Lieferschein:
- PDF-Gruppierung Abo- vs. Einmal-Artikel (is_abo_addon), rueckwaertskompatibel
- Vorschau-Command sales:preview-invoice (umgeht Test-System-Gate, ohne Rechnungsnummer)

Reminder-Mail (vorbereitet, Cron inaktiv):
- MailAboExecutionReminder + Template, abo:send-execution-reminder, Spalte onetime_reminder_sent_for
- Berater -> Sales Center, Kunde -> Portal; Vorschau abo:preview-reminder
- Datum-Fix: formatDateDB() statt formatDate() (ICU-Format)

Sperrfristen konfigurierbar (Admin-Settings):
- abo-lock-days-change (Liefertag + Abo-Produkte), abo-lock-days-pause-cancel
- Meldungen mit :lock-Platzhalter; Pausieren/Kuendigen vom Change-Lock entkoppelt
- Abo-Produkt-Aenderungen im Lock-Fenster server- + clientseitig eingefroren
- Selbstbedienung: Add-only/Lock nach $view (kein Admin-Bypass), Stale-Cache-Fix beim Mengen-Update

Dashboard:
- Einmalprodukte-Card + Team-Abo-KPI-Card, News-Card responsiv (col-xl-6)

Lokalisierung:
- Sprachdateien en/es/fr vollstaendig mit de synchronisiert (inkl. marketingplan/portal)
- LocalizationSyncTest sichert Key-Gleichheit ab

Tests: AboOneTimeInvoiceGrouping, AboExecutionReminder, AboLockDaysSetting,
AboOnetimeDashboardAndMail, PreviewInvoiceCommand, TeamAboOverview + erweiterte View-Tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-06-23 13:38:11 +00:00
parent 9f996a7f1b
commit 65b867f8ee
96 changed files with 4154 additions and 1189 deletions

View file

@ -0,0 +1,115 @@
<?php
use App\Mail\MailAboExecutionReminder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\Util;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(Tests\TestCase::class, RefreshDatabase::class);
/**
* Phase 6 – Reminder-E-Mail vor der Abo-Ausführung (Einmalprodukte-Fenster).
* Default-Fenster = 4 Tage. Berater (is_for=me) → Sales Center, Endkunde (ot) → Portal.
*/
beforeEach(function () {
makeShopEnv();
Mail::fake();
});
function makeOtAbo(?\Carbon\Carbon $nextDate = null): UserAbo
{
$shoppingUser = ShoppingUser::forceCreate([
'auth_user_id' => 1,
'billing_firstname' => 'Erika',
'billing_email' => 'kunde-'.uniqid('', true).'@example.com',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'wp_order_number' => 0,
'language' => 'de',
]);
return UserAbo::create([
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'ot',
'email' => $shoppingUser->billing_email,
'payone_userid' => 900200,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => $nextDate ?? now()->addDays(4),
]);
}
describe('AboSendExecutionReminder Selektion', function () {
it('versendet an ein Abo, dessen Ausführung im Fenster (heute + 4 Tage) liegt', function () {
$abo = makeMeAbo();
$abo->update(['next_date' => now()->addDays(4)]);
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
Mail::assertSent(MailAboExecutionReminder::class, 1);
});
it('versendet NICHT außerhalb des Fensters', function () {
$abo = makeMeAbo();
$abo->update(['next_date' => now()->addDays(10)]);
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
Mail::assertNothingSent();
});
it('versendet NICHT bei inaktivem oder falschem Status', function () {
makeMeAbo()->update(['next_date' => now()->addDays(4), 'active' => false]);
makeMeAbo()->update(['next_date' => now()->addDays(4), 'status' => 3]);
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
Mail::assertNothingSent();
});
});
describe('AboSendExecutionReminder Link-Wahl', function () {
it('Berater-Abo verlinkt ins Sales Center', function () {
$abo = makeMeAbo();
$abo->update(['next_date' => now()->addDays(4)]);
$html = (new MailAboExecutionReminder($abo->fresh()))->render();
expect($html)->toContain(rtrim(Util::getMyMivitaUrl(), '/').'/user/abos/detail/me/'.$abo->id);
});
it('Endkunden-Abo verlinkt ins Kundenportal', function () {
$abo = makeOtAbo();
$html = (new MailAboExecutionReminder($abo->fresh()))->render();
expect($html)->toContain(rtrim(Util::getMyMivitaPortalUrl(), '/').'/portal/my-subscriptions');
});
it('formatiert das Ausführungsdatum korrekt (kein doppeltes ICU-Format)', function () {
$abo = makeMeAbo();
$abo->update(['next_date' => '2026-07-10']);
$html = (new MailAboExecutionReminder($abo->fresh()))->render();
expect($html)->toContain('10.07.2026')
->and($html)->not->toContain('1010.0707');
});
});
describe('AboSendExecutionReminder Duplikat-Schutz', function () {
it('versendet pro Zyklus nur einmal', function () {
$abo = makeMeAbo();
$abo->update(['next_date' => now()->addDays(4)]);
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
$this->artisan('abo:send-execution-reminder')->assertSuccessful();
Mail::assertSent(MailAboExecutionReminder::class, 1);
expect($abo->fresh()->onetime_reminder_sent_for)->not->toBeNull();
});
});

View file

@ -0,0 +1,192 @@
<?php
use App\Models\Country;
use App\Models\Setting;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
use App\Services\AboHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
afterEach(fn () => Carbon::setTestNow());
describe('Abo-Sperrfristen als Setting', function () {
it('nutzt die Defaults, wenn kein Setting gesetzt ist', function () {
expect(AboRepository::getLockDaysChange())->toBe(10)
->and(AboRepository::getLockDaysPauseCancel())->toBe(3);
});
it('liest die konfigurierten Sperrfristen aus den Settings', function () {
Setting::setContentBySlug('abo-lock-days-change', 5, 'int');
Setting::setContentBySlug('abo-lock-days-pause-cancel', 7, 'int');
expect(AboRepository::getLockDaysChange())->toBe(5)
->and(AboRepository::getLockDaysPauseCancel())->toBe(7);
});
it('fällt bei 0 / leer auf den Default zurück', function () {
Setting::setContentBySlug('abo-lock-days-change', 0, 'int');
expect(AboRepository::getLockDaysChange())->toBe(10);
});
it('gibt eine Änderung frei, die bei Default gesperrt wäre, wenn die Sperrfrist reduziert wird', function () {
Carbon::setTestNow('2026-03-19 00:00:00');
// Liefertag 20, next_date in 6 Tagen → Liefertag-WECHSEL auf 25 testen.
$abo = makeLockTestAbo([
'abo_interval' => 20,
'next_date' => '2026-03-25',
]);
$this->actingAs($abo->user);
$repository = new AboRepository;
$repository->setModel($abo);
// Mit Default (10) ist der Wechsel gesperrt (6 < 10):
expect($repository->update([
'action' => 'abo_update_settings',
'id' => $abo->id,
'view' => 'me',
'abo_interval' => 25,
'abo_is_active' => 'true',
]))->toBeFalse();
// Sperrfrist auf 3 Tage reduzieren → Wechsel jetzt erlaubt (6 ≥ 3, neues Datum 2026-03-25):
Setting::setContentBySlug('abo-lock-days-change', 3, 'int');
expect($repository->update([
'action' => 'abo_update_settings',
'id' => $abo->id,
'view' => 'me',
'abo_interval' => 25,
'abo_is_active' => 'true',
]))->toBeTrue()
->and($abo->refresh()->abo_interval)->toBe(25);
});
it('zeigt die konfigurierte Sperrfrist in der Fehlermeldung (nicht den Default 10)', function () {
Carbon::setTestNow('2026-03-19 00:00:00');
app()->setLocale('de');
Setting::setContentBySlug('abo-lock-days-change', 3, 'int');
// Liefertag-WECHSEL (20 → 25), next_date morgen (1 Tag) → bei Sperrfrist 3 weiterhin gesperrt.
$abo = makeLockTestAbo(['abo_interval' => 20, 'next_date' => '2026-03-20']);
$this->actingAs($abo->user);
$repository = new AboRepository;
$repository->setModel($abo);
expect($repository->update([
'action' => 'abo_update_settings',
'id' => $abo->id,
'view' => 'me',
'abo_interval' => 25,
'abo_is_active' => 'true',
]))->toBeFalse()
->and(session('alert-error'))->toContain('mindestens 3 Tage')
->and(session('alert-error'))->not->toContain('mindestens 10 Tage');
});
it('erlaubt Pausieren im Change-Lock-Fenster, wenn außerhalb der Pause-Sperre (Intervall unverändert)', function () {
Carbon::setTestNow('2026-03-19 00:00:00');
// 5 Tage entfernt: innerhalb Change-Lock (10), außerhalb Pause-Lock (3).
$abo = makeLockTestAbo(['abo_interval' => 20, 'next_date' => '2026-03-24', 'active' => true, 'status' => 2]);
$this->actingAs($abo->user);
$repository = new AboRepository;
$repository->setModel($abo);
// Intervall unverändert (20), kein abo_is_active => Pausieren.
$result = $repository->update([
'action' => 'abo_update_settings',
'id' => $abo->id,
'view' => 'me',
'abo_interval' => 20,
]);
expect($result)->toBeTrue()
->and($abo->refresh()->active)->toBeFalse()
->and($abo->status)->toBe(6);
});
});
describe('isAboChangeLocked (UI-Sperre Liefertag)', function () {
afterEach(fn () => Carbon::setTestNow());
it('ist gesperrt im Fenster, frei außerhalb (User-Ansicht)', function () {
Carbon::setTestNow('2026-03-19 00:00:00');
$abo = makeLockTestAbo(['next_date' => '2026-03-20']); // 1 Tag → < Default 10
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeTrue();
$abo->next_date = '2026-04-19'; // weit entfernt
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse();
});
it('respektiert die konfigurierte Sperrfrist', function () {
Carbon::setTestNow('2026-03-19 00:00:00');
$abo = makeLockTestAbo(['next_date' => '2026-03-24']); // 5 Tage entfernt
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeTrue(); // 5 < 10 (Default)
Setting::setContentBySlug('abo-lock-days-change', 3, 'int');
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse(); // 5 >= 3
});
it('gilt nicht für Admin und nicht ohne next_date', function () {
Carbon::setTestNow('2026-03-19 00:00:00');
$abo = makeLockTestAbo(['next_date' => '2026-03-20']);
expect(AboHelper::isAboChangeLocked($abo, 'admin'))->toBeFalse();
$abo->next_date = null;
expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse();
});
});
/**
* @param array<string, mixed> $overrides
*/
function makeLockTestAbo(array $overrides = []): UserAbo
{
$country = Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Deutschland',
]);
$user = User::forceCreate([
'email' => 'abo-lock-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 0,
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'member_id' => $user->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => $user->email,
'is_for' => 'me',
'is_from' => 'user_order',
]);
return UserAbo::create(array_merge([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'me',
'email' => $user->email,
'payone_userid' => random_int(100000, 999999),
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => '2026-01-05',
'last_date' => '2026-02-05',
'next_date' => '2026-03-05',
], $overrides));
}

View file

@ -0,0 +1,110 @@
<?php
use App\Models\ShoppingOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
/**
* Phase 5 – Rechnung & Lieferschein: Positionen werden nach `is_abo_addon`
* gruppiert (Abo-Produkte oben, abgegrenzter Block „Einmalig hinzugefügte
* Artikel" darunter). Bestellungen ohne Einmal-Artikel bleiben unverändert.
*/
beforeEach(fn () => makeShopEnv());
function makeMixedOrder(bool $withAddon = true): ShoppingOrder
{
$order = ShoppingOrder::create([
'shopping_user_id' => 1,
'auth_user_id' => 1,
'country_id' => 1,
'user_shop_id' => 0,
'payment_for' => 6,
'shipping' => 0,
'shipping_net' => 0,
'subtotal_ws' => 100,
'tax' => 0,
'total_shipping' => 100,
'is_abo' => true,
]);
$aboProduct = makeProduct(['12'], 50, 0);
$order->shopping_order_items()->create([
'product_id' => $aboProduct->id,
'is_abo_addon' => false,
'qty' => 1,
'price' => 50,
'price_net' => 50,
'tax_rate' => 0,
'tax' => 0,
'price_vk_net' => 50,
'discount' => 0,
'points' => 0,
]);
if ($withAddon) {
$addonProduct = makeProduct(['2'], 30, 0);
$order->shopping_order_items()->create([
'product_id' => $addonProduct->id,
'is_abo_addon' => true,
'qty' => 1,
'price' => 30,
'price_net' => 30,
'tax_rate' => 0,
'tax' => 0,
'price_vk_net' => 30,
'discount' => 0,
'points' => 0,
]);
}
return $order->fresh('shopping_order_items.product');
}
describe('Rechnung-PDF Gruppierung', function () {
it('zeigt bei gemischter Bestellung beide Block-Überschriften, Abo vor Einmal', function () {
$order = makeMixedOrder();
$html = view('pdf.invoice-detail', ['shopping_order' => $order])->render();
expect($html)->toContain(__('pdf.abo_items_heading'))
->and($html)->toContain(__('pdf.abo_addon_items_heading'));
$aboPos = strpos($html, __('pdf.abo_items_heading'));
$addonPos = strpos($html, __('pdf.abo_addon_items_heading'));
expect($aboPos)->toBeLessThan($addonPos);
});
it('zeigt KEINE Block-Überschriften bei reiner Bestellung ohne Einmal-Artikel', function () {
$order = makeMixedOrder(withAddon: false);
$html = view('pdf.invoice-detail', ['shopping_order' => $order])->render();
expect($html)->not->toContain(__('pdf.abo_addon_items_heading'))
->and($html)->not->toContain(__('pdf.abo_items_heading'));
});
});
describe('Lieferschein-PDF Gruppierung', function () {
it('zeigt bei gemischter Bestellung beide Block-Überschriften, Abo vor Einmal', function () {
$order = makeMixedOrder();
$html = view('pdf.delivery-detail', ['shopping_order' => $order])->render();
expect($html)->toContain(__('pdf.abo_items_heading'))
->and($html)->toContain(__('pdf.abo_addon_items_heading'));
$aboPos = strpos($html, __('pdf.abo_items_heading'));
$addonPos = strpos($html, __('pdf.abo_addon_items_heading'));
expect($aboPos)->toBeLessThan($addonPos);
});
it('zeigt KEINE Block-Überschriften bei reiner Bestellung ohne Einmal-Artikel', function () {
$order = makeMixedOrder(withAddon: false);
$html = view('pdf.delivery-detail', ['shopping_order' => $order])->render();
expect($html)->not->toContain(__('pdf.abo_addon_items_heading'))
->and($html)->not->toContain(__('pdf.abo_items_heading'));
});
});

View file

@ -238,4 +238,74 @@ describe('Abo Einmal-Produkte Views', function () {
]))
->and($html)->toContain(__('abo.confirm_add_ok'));
});
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([
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1,
]);
// Relation cachen (wie initYard/makeOrderYard im Controller)
$abo->load('user_abo_items');
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]);
// Fix: Relation vor dem Render neu laden → gerenderte Menge = gespeicherter Stand
$abo->load('user_abo_items');
$html = view('admin.abo._order_abo_show', [
'user_abo' => $abo,
'split_mode' => true,
'summary' => ['abo' => ['gross' => 150], 'total_with_shipping' => 150],
])->render();
expect($html)->toContain('value="3"');
});
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([
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 2, 'status' => 1,
]);
$abo->load('user_abo_items');
$summary = ['abo' => ['gross' => 100], 'total_with_shipping' => 100];
$addOnly = view('admin.abo._order_abo_show', [
'user_abo' => $abo, 'split_mode' => true, 'summary' => $summary, 'add_only_mode' => true,
])->render();
expect($addOnly)->not->toContain('remove-from-basket')
->and($addOnly)->not->toContain('auto-delete-product')
->and($addOnly)->toContain('add-from-basket') // Hinzufügen bleibt möglich
->and($addOnly)->toContain('readonly');
$free = view('admin.abo._order_abo_show', [
'user_abo' => $abo, 'split_mode' => true, 'summary' => $summary, 'add_only_mode' => false,
])->render();
expect($free)->toContain('remove-from-basket')
->and($free)->toContain('auto-delete-product');
});
it('friert alle Produkt-Steuerungen ein, wenn der Change-Lock greift', function () {
$abo = makeMeAbo();
$product = makeProduct(['12'], 50, 0);
\App\Models\UserAboItem::create([
'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 2, 'status' => 1,
]);
$abo->load('user_abo_items');
$html = view('admin.abo._order_abo_show', [
'user_abo' => $abo, 'split_mode' => true,
'summary' => ['abo' => ['gross' => 100], 'total_with_shipping' => 100],
'change_locked' => true,
])->render();
expect($html)->not->toContain('add-from-basket')
->and($html)->not->toContain('remove-from-basket')
->and($html)->not->toContain('auto-delete-product')
->and($html)->toContain('readonly');
});
});

View file

@ -84,10 +84,10 @@ describe('isOneTimeWindowOpen', function () {
});
describe('isOneTimeFeatureVisible', function () {
it('ist sichtbar, wenn das Fenster offen ist und ein VIP-User (Admin) eingeloggt ist', function () {
$vip = new User;
$vip->admin = 1;
$this->actingAs($vip, 'user');
it('ist sichtbar, wenn das Fenster offen ist (Sales Center, eingeloggter User)', function () {
$user = new User;
$user->admin = 0;
$this->actingAs($user, 'user');
$abo = new UserAbo;
$abo->next_date = now()->addDays(2); // innerhalb des 4-Tage-Fensters
@ -95,29 +95,14 @@ describe('isOneTimeFeatureVisible', function () {
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue();
});
it('ist nicht sichtbar für eingeloggte Nicht-VIP-User', function () {
$user = new User;
$user->admin = 0;
$this->actingAs($user, 'user');
it('ist sichtbar ohne User auf dem user-Guard (Portal/Endkunde), solange das Fenster offen ist', function () {
$abo = new UserAbo;
$abo->next_date = now()->addDays(2);
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue();
});
it('ist nicht sichtbar ohne User auf dem user-Guard (Portal/Endkunde)', function () {
$abo = new UserAbo;
$abo->next_date = now()->addDays(2);
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
});
it('ist nicht sichtbar bei geschlossenem Fenster, auch für VIP-User', function () {
$vip = new User;
$vip->admin = 1;
$this->actingAs($vip, 'user');
it('ist nicht sichtbar bei geschlossenem Fenster', function () {
$abo = new UserAbo;
$abo->next_date = now()->addDays(30); // außerhalb des Fensters

View file

@ -0,0 +1,64 @@
<?php
use App\Mail\MailAboExecutionReminder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
uses(Tests\TestCase::class, RefreshDatabase::class);
beforeEach(fn () => makeShopEnv());
describe('Dashboard-Card Einmalprodukte', function () {
it('zeigt die Card mit Link zum eigenen Abo', function () {
// Domain-aware Route wird im Test-Kontext nicht geladen -> route() mocken
URL::shouldReceive('route')->andReturnUsing(
fn ($name, $params = [], $absolute = true) => '/user/abos/detail/'.($params['view'] ?? '').'/'.($params['id'] ?? '')
);
$abo = makeMeAbo();
$html = view('dashboard._abo_onetime', ['oneTimeAbo' => $abo])->render();
expect($html)->toContain(__('abo.onetime_card_hl'))
->and($html)->toContain(__('abo.add_onetime_product'))
->and($html)->toContain('/user/abos/detail/me/'.$abo->id);
});
it('rendert nichts, wenn kein Abo übergeben wird', function () {
$html = view('dashboard._abo_onetime', ['oneTimeAbo' => null])->render();
expect(trim($html))->toBe('');
});
});
describe('Reminder-Mail-Vorschau Command', function () {
it('rendert die Reminder-Mail als HTML ohne Versand', function () {
Storage::fake('public');
Mail::fake();
$abo = makeMeAbo();
$this->artisan('abo:preview-reminder', ['abo' => $abo->id])->assertExitCode(0);
Storage::disk('public')->assertExists('preview/abo-reminder-'.$abo->id.'.html');
$html = Storage::disk('public')->get('preview/abo-reminder-'.$abo->id.'.html');
expect($html)->toContain('/user/abos/detail/me/'.$abo->id);
Mail::assertNothingSent();
});
it('versendet zusätzlich bei --send (Mailpit-Test)', function () {
Storage::fake('public');
Mail::fake();
$abo = makeMeAbo();
$this->artisan('abo:preview-reminder', ['abo' => $abo->id, '--send' => 'test@example.com'])
->assertExitCode(0);
Mail::assertSent(MailAboExecutionReminder::class, 1);
});
it('lehnt ein unbekanntes Abo ab', function () {
$this->artisan('abo:preview-reminder', ['abo' => 999999])->assertExitCode(1);
});
});

View file

@ -0,0 +1,41 @@
<?php
uses(Tests\TestCase::class);
/**
* Stellt sicher, dass alle Übersetzungsdateien (en/es/fr) exakt dieselben Keys
* wie die deutsche Quelle haben – keine fehlenden und keine überzähligen Keys.
* Deutsch ist die maßgebliche Quelle.
*/
it('keeps translation files in sync with the german source keys', function (string $locale) {
foreach (glob(resource_path('lang/de/*.php')) as $germanFile) {
$fileName = basename($germanFile);
$localeFile = resource_path('lang/'.$locale.'/'.$fileName);
expect($localeFile)->toBeFile("Datei {$fileName} fehlt für Locale {$locale}");
expect(localizationSyncFlattenKeys(require $localeFile))
->toEqualCanonicalizing(localizationSyncFlattenKeys(require $germanFile));
}
})->with(['en', 'es', 'fr']);
/**
* @return array<int, string>
*/
function localizationSyncFlattenKeys(array $translations, string $prefix = ''): array
{
$keys = [];
foreach ($translations as $key => $value) {
$translationKey = $prefix.(string) $key;
if (is_array($value)) {
$keys = [...$keys, ...localizationSyncFlattenKeys($value, $translationKey.'.')];
continue;
}
$keys[] = $translationKey;
}
return $keys;
}

View file

@ -0,0 +1,75 @@
<?php
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserInvoice;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(Tests\TestCase::class, RefreshDatabase::class);
beforeEach(fn () => makeShopEnv());
function makePreviewOrder(): ShoppingOrder
{
$shoppingUser = ShoppingUser::forceCreate([
'auth_user_id' => 0,
'billing_firstname' => 'Max',
'billing_lastname' => 'Mustermann',
'billing_email' => 'preview@example.com',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'wp_order_number' => 0,
'language' => 'de',
]);
$order = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => 0,
'country_id' => 1,
'user_shop_id' => 0,
'payment_for' => 6,
'shipping' => 0,
'shipping_net' => 0,
'subtotal_ws' => 80,
'subtotal' => 80,
'tax' => 0,
'total_shipping' => 80,
'points' => 0,
'is_abo' => true,
]);
$aboProduct = makeProduct(['12'], 50, 0);
$order->shopping_order_items()->create([
'product_id' => $aboProduct->id, 'is_abo_addon' => false, 'qty' => 1,
'price' => 50, 'price_net' => 50, 'tax_rate' => 0, 'tax' => 0, 'price_vk_net' => 50, 'discount' => 0, 'points' => 0,
]);
$addonProduct = makeProduct(['2'], 30, 0);
$order->shopping_order_items()->create([
'product_id' => $addonProduct->id, 'is_abo_addon' => true, 'qty' => 1,
'price' => 30, 'price_net' => 30, 'tax_rate' => 0, 'tax' => 0, 'price_vk_net' => 30, 'discount' => 0, 'points' => 0,
]);
return $order->fresh('shopping_order_items.product');
}
it('lehnt eine unbekannte Bestellung ab', function () {
$this->artisan('sales:preview-invoice', ['order' => 999999])
->expectsOutputToContain('nicht gefunden')
->assertExitCode(1);
});
it('erzeugt Vorschau-PDFs ohne offizielle Rechnung oder Nummernverbrauch', function () {
Storage::fake('public');
$order = makePreviewOrder();
$this->artisan('sales:preview-invoice', ['order' => $order->id])
->assertExitCode(0);
// Keine offizielle Rechnung angelegt, keine Rechnungsnummer verbraucht.
expect(UserInvoice::count())->toBe(0);
// Vorschau-PDFs liegen im Preview-Ordner.
Storage::disk('public')->assertExists('invoice/preview/VORSCHAU-'.$order->id.'-MIVITA-Rechnung.pdf');
Storage::disk('public')->assertExists('delivery/preview/VORSCHAU-'.$order->id.'-MIVITA-Lieferschein.pdf');
});

View file

@ -0,0 +1,266 @@
<?php
use App\Models\Product;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\HTMLHelper;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
if (! Schema::connection('sqlite')->hasColumn('products', 'slug')) {
Schema::connection('sqlite')->table('products', function ($table) {
$table->string('slug')->nullable();
});
}
});
function makeUser(?int $sponsorId = null): User
{
$user = User::forceCreate([
'email' => 'u-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => now()->addYear(),
]);
return $user;
}
function makeAbo(int $userId, int $memberId, string $isFor, string $startDate, int $points = 0): UserAbo
{
$abo = UserAbo::create([
'user_id' => $userId,
'member_id' => $memberId,
'shopping_user_id' => 0,
'email' => 'a-'.uniqid('', true).'@example.com',
'is_for' => $isFor,
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => $startDate,
'last_date' => $startDate,
'next_date' => '2026-12-10',
]);
if ($points > 0) {
$product = Product::forceCreate([
'name' => 'P-'.uniqid('', true),
'title' => 'Test',
'price' => 10.0,
'tax' => 19.0,
'active' => true,
'pos' => 1,
'weight' => 100,
'points' => $points,
'slug' => 'p-'.uniqid('', true),
]);
UserAboItem::create([
'user_abo_id' => $abo->id,
'product_id' => $product->id,
'comp' => 0,
'qty' => 1,
'status' => 1,
]);
}
return $abo->fresh();
}
it('gruppiert Team-Abos nach Ebenen mit Eigen- und Kundenabos', function (): void {
$root = makeUser();
// 1. Ebene: zwei direkte Partner
$anna = makeUser($root->id);
$lena = makeUser($root->id);
// 2. Ebene: ein Partner unter Anna
$lisa = makeUser($anna->id);
// Anna hat ein Eigen-Abo (50 Punkte) und 2 Kundenabos
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
makeAbo($anna->id, $anna->id, 'ot', '2026-05-10', 30);
// Lena hat kein Eigen-Abo, aber 1 Kundenabo
makeAbo($lena->id, $lena->id, 'ot', '2026-06-12', 15);
// Lisa (Ebene 2) hat ein Eigen-Abo
makeAbo($lisa->id, $lisa->id, 'me', '2026-06-01', 40);
$service = app(TeamAboOverviewService::class);
$overview = $service->linesOverview($root, 6, 2026);
expect($overview['lines'])->toHaveKey(1)
->and($overview['lines'])->toHaveKey(2);
$line1 = $overview['lines'][1];
expect($line1['consultants'])->toBe(2)
->and($line1['members_with_own_abo'])->toBe(1) // nur Anna
->and($line1['own_abos'])->toBe(1)
->and($line1['customer_abos'])->toBe(3) // Anna 2 + Lena 1
->and($line1['customer_abos_new'])->toBe(2) // im Juni: Anna 1 + Lena 1 (Mai zählt nicht)
->and($line1['own_abos_new'])->toBe(1);
$line2 = $overview['lines'][2];
expect($line2['consultants'])->toBe(1)
->and($line2['own_abos'])->toBe(1)
->and($line2['customer_abos'])->toBe(0);
$totals = $overview['totals'];
expect($totals['own_abos'])->toBe(2)
->and($totals['customer_abos'])->toBe(3)
->and($totals['total_abos'])->toBe(5);
});
it('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void {
$root = makeUser();
$anna = makeUser($root->id);
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
$service = app(TeamAboOverviewService::class);
$overview = $service->linesOverview($root, 6, 2026);
$member = collect($overview['lines'][1]['members'])->firstWhere('user_id', $anna->id);
expect($member['has_own_abo'])->toBeTrue()
->and($member['own_abo_points'])->toBe(50.0)
->and($member['customer_abo_count'])->toBe(1)
->and($member['customer_abo_points'])->toBe(20.0)
->and($member['total_abos'])->toBe(2);
});
it('rendert die Team-Abo-Linienansicht mit Ebenen und Summen', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters));
$root = makeUser();
$anna = makeUser($root->id);
makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
$this->actingAs($root);
$service = app(TeamAboOverviewService::class);
$overview = $service->linesOverview($root, 6, 2026);
$growth = $service->monthlyGrowth($root, 2026);
$html = view('user.team.abos', [
'currentMonthName' => HTMLHelper::getTransMonths()[6],
'overview' => $overview,
'growth' => $growth,
'growthYear' => 2026,
'filterYears' => HTMLHelper::getYearRange(2022),
])->render();
expect($html)
->toContain(__('abo.team_by_level'))
->toContain(__('abo.team_total_abos'))
->toContain(__('abo.team_growth'))
->toContain($anna->email);
});
it('berechnet das monatliche Abo-Wachstum je Jahr', function (): void {
$root = makeUser();
$anna = makeUser($root->id);
$lena = makeUser($root->id);
// Juni 2026: 1 Eigen-Abo + 2 Kundenabos
makeAbo($anna->id, $anna->id, 'me', '2026-06-05');
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10');
makeAbo($lena->id, $lena->id, 'ot', '2026-06-20');
// März 2026: 1 Kundenabo
makeAbo($anna->id, $anna->id, 'ot', '2026-03-15');
// Anderes Jahr: zählt nicht
makeAbo($anna->id, $anna->id, 'ot', '2025-06-01');
$growth = app(TeamAboOverviewService::class)->monthlyGrowth($root, 2026);
expect($growth['year'])->toBe(2026)
->and($growth['months'])->toHaveCount(12);
$june = collect($growth['months'])->firstWhere('month', 6);
$march = collect($growth['months'])->firstWhere('month', 3);
expect($june['own_new'])->toBe(1)
->and($june['customer_new'])->toBe(2)
->and($june['total_new'])->toBe(3)
->and($march['customer_new'])->toBe(1)
->and($march['own_new'])->toBe(0);
expect($growth['totals']['own_new'])->toBe(1)
->and($growth['totals']['customer_new'])->toBe(3)
->and($growth['totals']['total_new'])->toBe(4);
});
it('liefert leichtgewichtige Dashboard-KPIs inkl. 1000-Punkte-Shop', function (): void {
$root = makeUser();
$anna = makeUser($root->id);
$lena = makeUser($root->id);
// Abos
makeAbo($anna->id, $anna->id, 'me', '2026-06-05'); // 1 Eigen-Abo
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10'); // 1 Kundenabo
makeAbo($lena->id, $lena->id, 'ot', '2026-06-12'); // 1 Kundenabo
// Anna erreicht 1000 Punkte Kundenumsatz im Juni 2026
UserSalesVolume::create([
'user_id' => $anna->id,
'month' => 6,
'year' => 2026,
'month_KP_points' => 600,
'month_shop_points' => 500,
'status' => 1,
]);
$kpis = app(TeamAboOverviewService::class)->teamKpis($root, 6, 2026);
expect($kpis['team_partner_abos'])->toBe(1)
->and($kpis['team_customer_abos'])->toBe(2)
->and($kpis['team_total_abos'])->toBe(3)
->and($kpis['shop_1000'])->toBe(1);
});
it('liefert die Abo-Zusammenfassung eines Team-Mitglieds inkl. Ausführungen', function (): void {
$root = makeUser();
$anna = makeUser($root->id);
$ownAbo = makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50);
makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20);
makeAbo($anna->id, $anna->id, 'ot', '2026-05-01', 30);
// Zwei ausgeführte (bezahlte) Lieferungen für das Eigen-Abo
UserAboOrder::create(['user_abo_id' => $ownAbo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]);
UserAboOrder::create(['user_abo_id' => $ownAbo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]);
$summary = app(TeamAboOverviewService::class)->memberAboSummary($root, $anna->id);
expect($summary)->not->toBeNull()
->and($summary['email'])->toBe($anna->email)
->and($summary['own_abo'])->not->toBeNull()
->and($summary['own_abo']['points'])->toBe(50.0)
->and($summary['own_abo']['executions'])->toBe(2)
->and($summary['customer_abos']['count'])->toBe(2)
->and($summary['customer_abos']['total_points'])->toBe(50.0)
->and($summary['customer_abos']['items'])->toHaveCount(2);
});
it('liefert null, wenn das Mitglied nicht im Team liegt', function (): void {
$root = makeUser();
$stranger = makeUser(); // kein Sponsor → nicht in der Downline
$summary = app(TeamAboOverviewService::class)->memberAboSummary($root, $stranger->id);
expect($summary)->toBeNull();
});