diff --git a/app/Console/Commands/DeleteAllKasSubdomains.php b/app/Console/Commands/DeleteAllKasSubdomains.php deleted file mode 100644 index b215265e..00000000 --- a/app/Console/Commands/DeleteAllKasSubdomains.php +++ /dev/null @@ -1,214 +0,0 @@ -option('domain') !== null - ? trim((string) $this->option('domain'), '.') - : null; - $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; - $isForce = (bool) $this->option('force'); - - if ($domain === '') { - $this->error('Die Option --domain darf nicht leer sein.'); - - return self::FAILURE; - } - - if ($limit !== null && $limit < 1) { - $this->error('Die Option --limit muss größer als 0 sein.'); - - return self::FAILURE; - } - - try { - $candidates = $this->getKasSubdomains($domain); - - if ($limit !== null) { - $candidates = $candidates->take($limit)->values(); - } - - $listFilePath = $this->writeTemporaryList($candidates, $domain, $isForce); - $this->printPreview($candidates, $domain, $listFilePath, $isForce); - - if ($candidates->isEmpty()) { - $this->info('Keine KAS-Subdomains gefunden.'); - - return self::SUCCESS; - } - - if (! $isForce) { - $this->warn('Dry-Run: Es wurde nichts gelöscht. Mit --force werden die Subdomains aus der temporären Liste gelöscht.'); - - return self::SUCCESS; - } - - return $this->deleteSubdomains($candidates); - } catch (Exception $e) { - $this->error('Fehler beim Löschen der KAS-Subdomains: '.$e->getMessage()); - Log::error('KAS Komplett-Subdomain-Löschung fehlgeschlagen', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - return self::FAILURE; - } - } - - private function getKasSubdomains(?string $domain): Collection - { - $this->info('Rufe Subdomains direkt von KAS ab...'); - - $subdomains = $this->kas->action('get_subdomains'); - - if (! is_iterable($subdomains)) { - throw new Exception('KAS hat keine auswertbare Subdomain-Liste zurückgegeben: '.json_encode($subdomains)); - } - - return collect($subdomains) - ->filter(function (array $subdomain) use ($domain): bool { - $name = strtolower((string) ($subdomain['subdomain_name'] ?? '')); - - if ($name === '') { - return false; - } - - return $domain === null || str_ends_with($name, '.'.strtolower($domain)); - }) - ->map(fn (array $subdomain): array => [ - 'subdomain_name' => (string) $subdomain['subdomain_name'], - 'subdomain_path' => (string) ($subdomain['subdomain_path'] ?? ''), - 'php_version' => (string) ($subdomain['php_version'] ?? ''), - 'is_active' => (string) ($subdomain['is_active'] ?? ''), - ]) - ->unique('subdomain_name') - ->sortBy('subdomain_name') - ->values(); - } - - private function writeTemporaryList(Collection $candidates, ?string $domain, bool $isForce): string - { - $listFilePath = $this->resolveListFilePath(); - File::ensureDirectoryExists(dirname($listFilePath)); - - File::put($listFilePath, json_encode([ - 'generated_at' => now()->toIso8601String(), - 'mode' => $isForce ? 'delete' : 'dry-run', - 'domain_filter' => $domain, - 'count' => $candidates->count(), - 'subdomains' => $candidates->values()->all(), - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - return $listFilePath; - } - - private function resolveListFilePath(): string - { - $listFilePath = (string) ($this->option('list-file') ?? ''); - - if ($listFilePath !== '') { - return str_starts_with($listFilePath, '/') ? $listFilePath : base_path($listFilePath); - } - - return storage_path('app/kas-subdomain-delete-lists/kas-subdomains-'.now()->format('Ymd-His').'.json'); - } - - private function printPreview(Collection $candidates, ?string $domain, string $listFilePath, bool $isForce): void - { - $this->newLine(); - $this->info('Modus: '.($isForce ? 'Löschen' : 'Dry-Run')); - $this->info('Domain-Filter: '.($domain ?: 'keiner')); - $this->info('Temporäre Liste: '.$listFilePath); - $this->info('Gefundene KAS-Subdomains: '.$candidates->count()); - - if ($candidates->isEmpty()) { - return; - } - - $this->table( - ['Subdomain', 'Pfad', 'PHP', 'Aktiv'], - $candidates->take(25)->map(fn (array $candidate): array => [ - $candidate['subdomain_name'], - $candidate['subdomain_path'] ?: '-', - $candidate['php_version'] ?: '-', - $candidate['is_active'] ?: '-', - ])->all() - ); - - if ($candidates->count() > 25) { - $this->line('... weitere '.($candidates->count() - 25).' Subdomains ausgeblendet.'); - } - } - - private function deleteSubdomains(Collection $candidates): int - { - $deleted = 0; - $failed = 0; - - foreach ($candidates as $candidate) { - $subdomainName = $candidate['subdomain_name']; - $result = $this->kas->action('delete_subdomain', [ - 'subdomain_name' => $subdomainName, - ]); - - if ($this->isSuccessfulDeleteResult($result, $subdomainName)) { - $deleted++; - $this->info("Gelöscht: {$subdomainName}"); - - continue; - } - - $failed++; - $this->error("Fehler beim Löschen von {$subdomainName}: ".json_encode($result)); - } - - $this->newLine(); - $this->info("Gelöscht: {$deleted}"); - $this->info("Fehler: {$failed}"); - - return $failed === 0 ? self::SUCCESS : self::FAILURE; - } - - private function isSuccessfulDeleteResult(mixed $result, string $subdomainName): bool - { - return $result === true - || $result === 'TRUE' - || $result === $subdomainName; - } -} diff --git a/app/Console/Commands/DeletePartnerShopSubdomains.php b/app/Console/Commands/DeletePartnerShopSubdomains.php deleted file mode 100644 index 2384cd50..00000000 --- a/app/Console/Commands/DeletePartnerShopSubdomains.php +++ /dev/null @@ -1,241 +0,0 @@ - - */ - private array $protectedPrefixes = ['www.', 'api.', 'checkout.', 'preview.']; - - public function __construct(private KasController $kas) - { - parent::__construct(); - } - - /** - * Execute the console command. - */ - public function handle(): int - { - $domain = trim((string) $this->option('domain'), '.'); - $startId = max(1, (int) $this->option('start')); - $limit = $this->option('limit') !== null ? (int) $this->option('limit') : null; - $isForce = (bool) $this->option('force'); - - if ($domain === '') { - $this->error('Die Option --domain darf nicht leer sein.'); - - return self::FAILURE; - } - - if ($limit !== null && $limit < 1) { - $this->error('Die Option --limit muss größer als 0 sein.'); - - return self::FAILURE; - } - - try { - $subdomains = $this->getKasSubdomains(); - $shopHosts = $this->getPartnerShopHosts($domain, $startId); - $candidates = $this->getDeletionCandidates($subdomains, $shopHosts, $domain); - - if ($limit !== null) { - $candidates = $candidates->take($limit)->values(); - } - - $this->printPreview($candidates, $isForce); - - if ($candidates->isEmpty()) { - $this->info('Keine löschbaren Partnershop-Subdomains gefunden.'); - - return self::SUCCESS; - } - - if (! $isForce) { - $this->warn('Dry-Run: Es wurde nichts gelöscht. Mit --force werden die gefundenen Subdomains gelöscht.'); - - return self::SUCCESS; - } - - return $this->deleteSubdomains($candidates); - } catch (Exception $e) { - $this->error('Fehler beim Löschen der Partnershop-Subdomains: '.$e->getMessage()); - Log::error('KAS Partnershop-Subdomain-Löschung fehlgeschlagen', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - return self::FAILURE; - } - } - - private function getKasSubdomains(): Collection - { - $this->info('Rufe Subdomains von KAS ab...'); - - $subdomains = $this->kas->action('get_subdomains'); - - if (! is_iterable($subdomains)) { - throw new Exception('KAS hat keine auswertbare Subdomain-Liste zurückgegeben: '.json_encode($subdomains)); - } - - return collect($subdomains); - } - - private function getPartnerShopHosts(string $domain, int $startId): Collection - { - $query = UserShop::query() - ->where('id', '>=', $startId) - ->orderBy('id'); - - if ($this->option('active-only')) { - $query->where('active', true); - } else { - $query->withTrashed(); - } - - return $query - ->pluck('slug') - ->filter() - ->map(fn (string $slug): string => strtolower($slug.'.'.$domain)) - ->unique() - ->values(); - } - - private function getDeletionCandidates(Collection $subdomains, Collection $shopHosts, string $domain): Collection - { - $shopHostLookup = array_flip($shopHosts->all()); - $includePathMatches = (bool) $this->option('include-path-matches'); - $partnerShopPath = (string) $this->option('path'); - - return $subdomains - ->filter(function (array $subdomain) use ($domain, $includePathMatches, $partnerShopPath, $shopHostLookup): bool { - $name = strtolower((string) ($subdomain['subdomain_name'] ?? '')); - - if ($name === '' || ! $this->belongsToDomain($name, $domain) || $this->isProtectedSubdomain($name)) { - return false; - } - - if (isset($shopHostLookup[$name])) { - return true; - } - - return $includePathMatches - && trim((string) ($subdomain['subdomain_path'] ?? '')) === $partnerShopPath; - }) - ->map(fn (array $subdomain): array => [ - 'subdomain_name' => (string) $subdomain['subdomain_name'], - 'subdomain_path' => (string) ($subdomain['subdomain_path'] ?? ''), - 'php_version' => (string) ($subdomain['php_version'] ?? ''), - ]) - ->unique('subdomain_name') - ->sortBy('subdomain_name') - ->values(); - } - - private function belongsToDomain(string $subdomainName, string $domain): bool - { - return str_ends_with($subdomainName, '.'.strtolower($domain)); - } - - private function isProtectedSubdomain(string $subdomainName): bool - { - foreach ($this->protectedPrefixes as $prefix) { - if (str_starts_with($subdomainName, $prefix)) { - return true; - } - } - - return false; - } - - private function printPreview(Collection $candidates, bool $isForce): void - { - $this->newLine(); - $this->info('Modus: '.($isForce ? 'Löschen' : 'Dry-Run')); - $this->info('Gefundene Lösch-Kandidaten: '.$candidates->count()); - - if ($candidates->isEmpty()) { - return; - } - - $this->table( - ['Subdomain', 'Pfad', 'PHP'], - $candidates->take(25)->map(fn (array $candidate): array => [ - $candidate['subdomain_name'], - $candidate['subdomain_path'] ?: '-', - $candidate['php_version'] ?: '-', - ])->all() - ); - - if ($candidates->count() > 25) { - $this->line('... weitere '.($candidates->count() - 25).' Subdomains ausgeblendet.'); - } - } - - private function deleteSubdomains(Collection $candidates): int - { - $deleted = 0; - $failed = 0; - - foreach ($candidates as $candidate) { - $subdomainName = $candidate['subdomain_name']; - $result = $this->kas->action('delete_subdomain', [ - 'subdomain_name' => $subdomainName, - ]); - - if ($this->isSuccessfulDeleteResult($result, $subdomainName)) { - $deleted++; - $this->info("Gelöscht: {$subdomainName}"); - - continue; - } - - $failed++; - $this->error("Fehler beim Löschen von {$subdomainName}: ".json_encode($result)); - } - - $this->newLine(); - $this->info("Gelöscht: {$deleted}"); - $this->info("Fehler: {$failed}"); - - return $failed === 0 ? self::SUCCESS : self::FAILURE; - } - - private function isSuccessfulDeleteResult(mixed $result, string $subdomainName): bool - { - return $result === true - || $result === 'TRUE' - || $result === $subdomainName; - } -} diff --git a/app/Console/Commands/GdprDeleteUser.php b/app/Console/Commands/GdprDeleteUser.php new file mode 100644 index 00000000..cb603811 --- /dev/null +++ b/app/Console/Commands/GdprDeleteUser.php @@ -0,0 +1,72 @@ +argument('id'); + $isCustomer = (bool) $this->option('customer'); + + if ($isCustomer) { + $model = ShoppingUser::withTrashed()->find($id); + $label = $model ? 'Endkunde #'.$model->id.' ('.$model->billing_email.')' : null; + } else { + $model = User::withTrashed()->find($id); + $label = $model ? 'Berater #'.$model->id.' ('.$model->email.')' : null; + } + + if (! $model) { + $this->error('ERROR: Datensatz nicht gefunden, id: '.$id); + + return 1; + } + + if (! $this->option('force') && ! $this->confirm('DSGVO-Löschung für '.$label.' UNWIDERRUFLICH ausführen?')) { + $this->info('Abgebrochen.'); + + return 0; + } + + try { + $summary = $isCustomer + ? $gdprDeletionService->deleteShoppingUser($model) + : $gdprDeletionService->deleteUser($model); + + $this->info('SUCCESS: DSGVO-Löschung ausgeführt: '.json_encode($summary)); + + return 0; + } catch (GdprDeletionException $e) { + $this->error('ERROR: '.$e->getMessage()); + + return 1; + } + } +} diff --git a/app/Console/Commands/SubDomains.php b/app/Console/Commands/SubDomains.php deleted file mode 100644 index 9687fe3e..00000000 --- a/app/Console/Commands/SubDomains.php +++ /dev/null @@ -1,664 +0,0 @@ -timeStart = microtime(true); - - $userId = $this->argument('user_id'); - $force = $this->option('force'); - $startId = $this->option('start'); - $createMissing = $this->option('create-missing'); - $debug = $this->option('debug'); - - if ($debug) { - $this->warn("DEBUG-MODUS (DRY-RUN): Es werden keine tatsächlichen Änderungen vorgenommen!"); - } - - $this->info("Starte Subdomain-Verwaltung" . ($force ? " (erzwungener Modus)" : "")); - - try { - if ($userId) { - $this->info("Verarbeite einzelnen Benutzer mit ID: {$userId}"); - $result = $this->syncSingleUser($userId, $force, $createMissing, $debug); - - if ($result) { - $this->info("Benutzer {$userId} erfolgreich synchronisiert" . ($debug ? " (simuliert)" : "")); - } else { - $this->warn("Benutzer {$userId} konnte nicht vollständig synchronisiert werden" . ($debug ? " (simuliert)" : "")); - } - } else { - $this->info("Verarbeite alle Benutzer ab ID: {$startId}"); - $result = $this->syncAllUsers($force, $startId, $createMissing, $debug); - - // Zusammenfassung der Ergebnisse - $this->displaySummary($result, $debug); - } - - $this->logExecutionTime("Subdomain-Verwaltung abgeschlossen" . ($debug ? " (DEBUG-MODUS)" : "")); - return 0; - } catch (Exception $e) { - $this->error("Ein Fehler ist aufgetreten: " . $e->getMessage()); - Log::error("Shopping User Sync Fehler: ", [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - 'user_id' => $userId, - 'force' => $force, - 'start_id' => $startId, - 'debug' => $debug - ]); - return 1; - } - } - - - /** - * Zeigt eine Zusammenfassung der Synchronisationsergebnisse an - * - * @param array $result Ergebnisdaten der Synchronisation - * @param bool $debug Debug-Modus (Dry-Run) - * @return void - */ - private function displaySummary($result, $debug = false) - { - $this->line(""); - $this->info("=== Zusammenfassung " . ($debug ? "(DEBUG-MODUS)" : "") . " ==="); - $this->info("Verarbeitete Shops: " . count($result['shops'])); - $this->info("Aktualisierte PHP-Versionen: " . $result['updatedCount'] . ($debug ? " (simuliert)" : "")); - $this->info("Aktivierte SSL-Zertifikate: " . $result['sslEnabledCount'] . ($debug ? " (simuliert)" : "")); - $this->info("Aktualisierte SSL-Konfigurationen: " . $result['sslConfiguredCount'] . ($debug ? " (simuliert)" : "")); - - if (!empty($result['createdSubdomains'])) { - $this->info("Neu erstellte Subdomains: " . count($result['createdSubdomains']) . ($debug ? " (simuliert)" : "")); - } - - if (!empty($result['missingSubdomains'])) { - $this->warn("Fehlende Subdomains: " . count($result['missingSubdomains'])); - } - - if (!empty($result['unusedSubdomains'])) { - $this->warn("Ungenutzte Subdomains: " . count($result['unusedSubdomains'])); - } - - if (!empty($result['doubleDomains'])) { - $this->warn("Benutzer mit mehreren Shops: " . count($result['doubleDomains'])); - } - } - - /** - * Synchronisiert einen einzelnen Benutzer und seine Shops - * - * @param int $userId Benutzer-ID - * @param bool $force Erzwingt die Aktualisierung aller Subdomains - * @param bool $createMissing Erstellt fehlende Subdomains - * @param bool $debug Debug-Modus (Dry-Run) - * @return bool Erfolg der Operation - */ - private function syncSingleUser($userId, $force = false, $createMissing = false, $debug = false) - { - $this->info("Synchronisiere Benutzer mit ID: {$userId}"); - - // Benutzer-Shops abrufen - $userShops = UserShop::where('user_id', $userId)->get(); - - if ($userShops->isEmpty()) { - $this->warn("Keine Shops für Benutzer {$userId} gefunden"); - return false; - } - - $this->info("Gefundene Shops für Benutzer {$userId}: " . $userShops->count()); - - // Subdomains abrufen und filtern - $subdomains = $this->getFilteredSubdomains(); - $success = true; - - // Benutzer-Shops durchlaufen und mit Subdomains abgleichen - foreach ($userShops as $userShop) { - $fullDomainName = $userShop->slug . '.' . $this->domain; - $this->info("Verarbeite Shop: {$fullDomainName}"); - - // Prüfen, ob Subdomain existiert - if (array_key_exists($fullDomainName, $subdomains)) { - $success = $this->processExistingSubdomain($userShop, $subdomains[$fullDomainName], $force, $debug) && $success; - } else { - // Subdomain fehlt - $this->warn("Shop {$userShop->slug}: Keine Subdomain gefunden"); - - // Optional: Neue Subdomain erstellen - if ($createMissing) { - $this->info("Erstelle fehlende Subdomain für Shop {$userShop->slug}" . ($debug ? " (simuliert)" : "")); - $success = $this->createSubdomain($userShop->slug, $debug) && $success; - } else { - $success = false; - } - } - } - - return $success; - } - - /** - * Verarbeitet eine existierende Subdomain und aktualisiert sie bei Bedarf - * - * @param UserShop $userShop Shop-Objekt - * @param array $subdomainInfo Subdomain-Informationen - * @param bool $force Erzwingt die Aktualisierung - * @param bool $debug Debug-Modus (Dry-Run) - * @return bool Erfolg der Operation - */ - private function processExistingSubdomain($userShop, $subdomainInfo, $force, $debug = false) - { - $success = true; - $hasSSL = ($subdomainInfo['ssl_certificate_sni'] === 'Y' || - $subdomainInfo['ssl_proxy'] === 'Y'); - $sslActive = ($subdomainInfo['ssl_certificate_sni_is_active'] ?? 'N') === 'Y'; - $phpVersion = $subdomainInfo['php_version']; - - $this->info("Shop {$userShop->slug}: PHP-Version: {$phpVersion}, SSL: " . - ($hasSSL ? "Aktiviert" : "Nicht aktiviert") . - ($hasSSL ? ", SSL aktiv: " . ($sslActive ? "Ja" : "Nein") : "")); - - // Prüfen, ob PHP-Version aktualisiert werden muss - $requiredPhpVersion = config('app.php_version'); - if ($force || $phpVersion !== $requiredPhpVersion) { - $this->info("Shop {$userShop->slug}: PHP-Version aktualisieren von {$phpVersion} auf {$requiredPhpVersion}" . ($debug ? " (simuliert)" : "")); - - if (!$debug && !$this->updateSubdomainParams($userShop->slug, $requiredPhpVersion)) { - $this->error("PHP-Version für {$userShop->slug}.{$this->domain} konnte nicht aktualisiert werden"); - $success = false; - } - } - - // Prüfen, ob SSL aktiviert werden muss - if ($force || !$hasSSL) { - $this->info("Shop {$userShop->slug}: SSL aktivieren" . ($debug ? " (simuliert)" : "")); - - if (!$debug && !$this->enableSSL($userShop->slug)) { - $this->error("SSL für {$userShop->slug}.{$this->domain} konnte nicht aktiviert werden"); - $success = false; - } - } - // Prüfen, ob SSL-Konfiguration aktualisiert werden muss - else if ($force || ($hasSSL && !$sslActive)) { - $this->info("Shop {$userShop->slug}: SSL-Konfiguration aktualisieren" . ($debug ? " (simuliert)" : "")); - - if (!$debug && !$this->updateSSL($userShop->slug . '.' . $this->domain)) { - $this->error("SSL-Konfiguration für {$userShop->slug}.{$this->domain} konnte nicht aktualisiert werden"); - $success = false; - } - } - - return $success; - } - - /** - * Synchronisiert alle Benutzer-Shops - * - * @param bool $force Erzwingt die Aktualisierung aller Subdomains - * @param int $startId Beginnt die Synchronisation ab dieser ID - * @param bool $createMissing Erstellt fehlende Subdomains - * @param bool $debug Debug-Modus (Dry-Run) - * @return array Ergebnisdaten der Synchronisation - */ - private function syncAllUsers($force, $startId, $createMissing = false, $debug = false) - { - $this->info("Starte Synchronisation aller Benutzer-Shops ab ID: {$startId}"); - - // Benutzer-Shops abrufen - $userShopsQuery = UserShop::query(); - - if ($startId > 1) { - $userShopsQuery->where('id', '>=', $startId); - } - - $userShops = $userShopsQuery->limit(1000)->get(); - $this->info("Gefundene Benutzer-Shops: " . $userShops->count()); - - // Subdomains abrufen und filtern - $subdomains = $this->getFilteredSubdomains(); - $this->info("Gefilterte Subdomains: " . count($subdomains)); - - // Ergebnis-Arrays initialisieren - $doubleDomains = []; - $missingSubdomains = []; - $outdatedPhpVersions = []; - $missingSSL = []; - $sslConfigurationNeeded = []; - $createdSubdomains = []; - $updatedCount = 0; - $sslEnabledCount = 0; - $sslConfiguredCount = 0; - - // Benutzer-Shops durchlaufen und mit Subdomains abgleichen - foreach ($userShops as $userShop) { - $fullDomainName = $userShop->slug . '.' . $this->domain; - - // Status der Subdomain setzen - $userShop->hasSubdomain = false; - $userShop->hasSSL = false; - $userShop->sslActive = false; - $userShop->PHPversion = ""; - - // Prüfen, ob Subdomain existiert - if (array_key_exists($fullDomainName, $subdomains)) { - $userShop->hasSubdomain = true; - $userShop->hasSSL = ($subdomains[$fullDomainName]['ssl_certificate_sni'] === 'Y' || - $subdomains[$fullDomainName]['ssl_proxy'] === 'Y'); - $userShop->sslActive = ($subdomains[$fullDomainName]['ssl_certificate_sni_is_active'] ?? 'N') === 'Y'; - $userShop->PHPversion = $subdomains[$fullDomainName]['php_version']; - - // Prüfen, ob PHP-Version aktualisiert werden muss - $requiredPhpVersion = config('app.php_version'); - if ($force || $userShop->PHPversion !== $requiredPhpVersion) { - $this->info("Shop {$userShop->slug}: PHP-Version aktualisieren von {$userShop->PHPversion} auf {$requiredPhpVersion}" . ($debug ? " (simuliert)" : "")); - $outdatedPhpVersions[] = $userShop->slug; - - if (!$debug && $this->updateSubdomainParams($userShop->slug, $requiredPhpVersion)) { - $updatedCount++; - } else if ($debug) { - // Im Debug-Modus zählen wir trotzdem, als ob es erfolgreich wäre - $updatedCount++; - } - }else{ - $this->info("Shop {$userShop->slug}: PHP-Version ist aktuell: {$userShop->PHPversion}"); - } - - // Prüfen, ob SSL aktiviert werden muss - /* if ($force || !$userShop->hasSSL) { - $this->info("Shop {$userShop->slug}: SSL aktivieren" . ($debug ? " (simuliert)" : "")); - $missingSSL[] = $userShop->slug; - - if (!$debug && $this->enableSSL($userShop->slug)) { - $sslEnabledCount++; - } else if ($debug) { - // Im Debug-Modus zählen wir trotzdem, als ob es erfolgreich wäre - $sslEnabledCount++; - } - } - // Prüfen, ob SSL-Konfiguration aktualisiert werden muss - else if ($force || ($userShop->hasSSL && !$userShop->sslActive)) { - $this->info("Shop {$userShop->slug}: SSL-Konfiguration aktualisieren" . ($debug ? " (simuliert)" : "")); - $sslConfigurationNeeded[] = $userShop->slug; - - if (!$debug && $this->updateSSL($fullDomainName)) { - $sslConfiguredCount++; - } else if ($debug) { - // Im Debug-Modus zählen wir trotzdem, als ob es erfolgreich wäre - $sslConfiguredCount++; - } - }*/ - - // Subdomain aus der Liste entfernen, um später ungenutzte zu identifizieren - unset($subdomains[$fullDomainName]); - } else { - // Subdomain fehlt - $missingSubdomains[] = $userShop->slug; - $this->warn("Shop {$userShop->slug}: Keine Subdomain gefunden"); - - // Optional: Neue Subdomain erstellen - if ($createMissing) { - $this->info("Erstelle fehlende Subdomain für Shop {$userShop->slug}" . ($debug ? " (simuliert)" : "")); - if (!$debug && $this->createSubdomain($userShop->slug)) { - $createdSubdomains[] = $userShop->slug; - } else if ($debug) { - // Im Debug-Modus zählen wir trotzdem, als ob es erfolgreich wäre - $createdSubdomains[] = $userShop->slug; - } - } - } - - // Doppelte Domains pro Benutzer erfassen - $doubleDomains[$userShop->user_id][$userShop->id] = $fullDomainName; - } - - // Bereinigen der doppelten Domains (nur Benutzer mit mehreren Shops behalten) - foreach ($doubleDomains as $userId => $domains) { - if (count($domains) === 1) { - unset($doubleDomains[$userId]); - } - } - - $this->logExecutionTime("Synchronisation abgeschlossen" . ($debug ? " (DEBUG-MODUS)" : "")); - - // Ergebnisdaten zurückgeben - return [ - 'shops' => $userShops, - 'unusedSubdomains' => $subdomains, - 'doubleDomains' => $doubleDomains, - 'missingSubdomains' => $missingSubdomains, - 'outdatedPhpVersions' => $outdatedPhpVersions, - 'missingSSL' => $missingSSL, - 'sslConfigurationNeeded' => $sslConfigurationNeeded, - 'createdSubdomains' => $createdSubdomains, - 'updatedCount' => $updatedCount, - 'sslEnabledCount' => $sslEnabledCount, - 'sslConfiguredCount' => $sslConfiguredCount - ]; - } - - /** - * Ruft alle Subdomains ab und filtert sie - * - * @return array Gefilterte Subdomains - */ - private function getFilteredSubdomains() - { - $kas = new KasController(); - $subdomains = []; - - // Alle Subdomains abrufen - $this->info("Rufe Subdomains von KAS ab..."); - $getSubdomains = $kas->action('get_subdomains'); - - // Subdomains filtern und in ein leicht zugängliches Array umwandeln - foreach ($getSubdomains as $subdomain) { - if (!isset($subdomain['subdomain_name'])) { - continue; - } - - // Spezielle Subdomains überspringen - $skip = false; - foreach ($this->skipPrefixes as $prefix) { - if (strpos($subdomain['subdomain_name'], $prefix) !== false) { - $skip = true; - break; - } - } - - if ($skip) { - continue; - } - - // Subdomain-Informationen speichern - $subdomains[$subdomain['subdomain_name']] = [ - 'ssl_certificate_sni' => $subdomain['ssl_certificate_sni'] ?? 'N', - 'php_version' => $subdomain['php_version'] ?? '', - 'ssl_proxy' => $subdomain['ssl_proxy'] ?? 'N', - 'ssl_certificate_sni_is_active' => $subdomain['ssl_certificate_sni_is_active'] ?? 'N', - ]; - } - - return $subdomains; - } - - /** - * Ändert Parameter einer Subdomain, insbesondere die PHP-Version - * - * @param string $subdomain Name der Subdomain ohne Domain - * @param string $phpVersion Neue PHP-Version (z.B. '8.2') - * @param array $additionalParams Weitere zu ändernde Parameter - * @param bool $debug Debug-Modus (Dry-Run) - * @return bool Erfolg der Operation - */ - private function updateSubdomainParams($subdomain, $phpVersion, $additionalParams = [], $debug = false) - { - $this->info("Aktualisiere Parameter für: {$subdomain}.{$this->domain}" . ($debug ? " (simuliert)" : "")); - - if ($debug) { - $this->line(" - PHP-Version: {$phpVersion}"); - if (!empty($additionalParams)) { - $this->line(" - Zusätzliche Parameter: " . json_encode($additionalParams)); - } - return true; - } - - try { - $kas = new KasController(); - - // Standardparameter - $params = [ - 'subdomain_name' => $subdomain . '.' . $this->domain, // Vollständigen Domainnamen verwenden - 'php_version' => $phpVersion - ]; - - // Oder alternativ, falls die API die Subdomain und Domain getrennt erwartet: - // $params = [ - // 'subdomain_name' => $subdomain, - // 'domain_name' => $this->domain, - // 'php_version' => $phpVersion - // ]; - - // Zusätzliche Parameter hinzufügen - $params = array_merge($params, $additionalParams); - - // Subdomain aktualisieren - $result = $kas->action('update_subdomain', $params); - $this->info("Parameter: ".json_encode($params)); - - if ($result) { - $this->info("Parameter für {$subdomain}.{$this->domain} erfolgreich aktualisiert " . json_encode($result)); - return true; - } else { - $this->error("Fehler beim Aktualisieren der Parameter für {$subdomain}.{$this->domain}: " . json_encode($result)); - return false; - } - } catch (Exception $e) { - $this->error("Fehler beim Aktualisieren der Parameter für {$subdomain}.{$this->domain}: " . $e->getMessage()); - Log::error("Subdomain Parameter Update Fehler", [ - 'subdomain' => $subdomain, - 'domain' => $this->domain, - 'php_version' => $phpVersion, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() - ]); - return false; - } - } - - /** - * Aktualisiert die SSL-Konfiguration einer Subdomain mit erweiterten Parametern - * - * @param string $subdomainName Vollständiger Domainname (z.B. 'shop.mivita.care') - * @param bool $debug Debug-Modus (Dry-Run) - * @return bool Erfolg der Operation - */ - private function updateSSL($subdomainName, $debug = false) - { - $this->info("Aktualisiere SSL-Konfiguration für: {$subdomainName}" . ($debug ? " (simuliert)" : "")); - - if ($debug) { - $this->line(" - SSL-Parameter werden aktualisiert"); - return true; - } - - try { - $kas = new KasController(); - $ssl = KasSLLController::getApiSSLParameter(); - - $params = array_merge(['hostname' => $subdomainName], $ssl); - $result = $kas->action('update_ssl', $params); - - if ($result === "TRUE" || $result === true) { - $this->info("SSL-Konfiguration für {$subdomainName} erfolgreich aktualisiert"); - return true; - } else { - $this->warn("SSL-Konfiguration für {$subdomainName} nicht vollständig aktualisiert: " . json_encode($result)); - return false; - } - } catch (Exception $e) { - $this->error("Fehler bei der SSL-Konfiguration für {$subdomainName}: " . $e->getMessage()); - Log::error("SSL Update Fehler", [ - 'subdomain' => $subdomainName, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() - ]); - return false; - } - } - - /** - * Aktiviert SSL für eine Subdomain mit vollständiger Konfiguration - * - * @param string $subdomain Name der Subdomain ohne Domain - * @param bool $debug Debug-Modus (Dry-Run) - * @return bool Erfolg der Operation - */ - private function enableSSL($subdomain, $debug = false) - { - $fullDomainName = $subdomain . '.' . $this->domain; - $this->info("Aktiviere SSL für: {$fullDomainName}" . ($debug ? " (simuliert)" : "")); - - if ($debug) { - $this->line(" - SSL-Proxy wird aktiviert"); - $this->line(" - HTTP zu HTTPS Weiterleitung wird eingerichtet"); - $this->line(" - SSL-Konfiguration wird aktualisiert"); - return true; - } - - // Schritt 1: Subdomain-Parameter aktualisieren (ssl_proxy aktivieren) - $subdomainResult = $this->updateSubdomainParams($subdomain, config('app.php_version'), [ - 'ssl_proxy' => 'Y', - 'redirect_status' => 301 // Weiterleitung von HTTP auf HTTPS - ]); - - if (!$subdomainResult) { - $this->error("SSL-Aktivierung für {$fullDomainName} fehlgeschlagen: Subdomain-Parameter konnten nicht aktualisiert werden"); - return false; - } - - // Schritt 2: SSL-Konfiguration aktualisieren - $sslResult = $this->updateSSL($fullDomainName); - - if (!$sslResult) { - $this->warn("SSL-Aktivierung für {$fullDomainName} teilweise erfolgreich: SSL-Konfiguration konnte nicht aktualisiert werden"); - return false; - } - - $this->info("SSL für {$fullDomainName} vollständig aktiviert"); - return true; - } - - /** - * Erstellt eine neue Subdomain für einen Shop - * - * @param string $slug Shop-Slug - * @param bool $debug Debug-Modus (Dry-Run) - * @return bool Erfolg der Operation - */ - private function createSubdomain($slug, $debug = false) - { - $fullDomainName = $slug . '.' . $this->domain; - $this->info("Erstelle neue Subdomain: {$fullDomainName}" . ($debug ? " (simuliert)" : "")); - - if ($debug) { - $this->line(" - Pfad: /mein.mivita.care/public/"); - $this->line(" - PHP-Version: " . config('app.php_version')); - $this->line(" - SSL wird direkt aktiviert"); - return true; - } - - try { - $kas = new KasController(); - - // Parameter für die neue Subdomain - $params = [ - 'subdomain_name' => $slug, - 'domain_name' => $this->domain, - 'subdomain_path' => '/mein.mivita.care/public/', - 'php_version' => config('app.php_version'), - ]; - - // Subdomain erstellen - $result = $kas->action('add_subdomain', $params); - - if ($result === $fullDomainName) { - $this->info("Subdomain {$fullDomainName} erfolgreich erstellt"); - - // SSL direkt aktivieren - $this->enableSSL($slug); - - return true; - } else { - $this->error("Fehler beim Erstellen der Subdomain {$fullDomainName}: " . json_encode($result)); - return false; - } - } catch (Exception $e) { - $this->error("Fehler beim Erstellen der Subdomain {$fullDomainName}: " . $e->getMessage()); - Log::error("Subdomain Erstellung Fehler", [ - 'slug' => $slug, - 'domain' => $this->domain, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() - ]); - return false; - } - } - - /** - * Protokolliert die Ausführungszeit einer Operation - * - * @param string $message Nachricht für die Protokollierung - * @return void - */ - private function logExecutionTime($message) - { - $diff = microtime(true) - $this->timeStart; - $sec = intval($diff); - $micro = $diff - $sec; - - $this->info($message. ' | Time: '.$sec. 'sec :' . round($micro * 1000, 4) . " ms"); - } -} diff --git a/app/Exceptions/GdprDeletionException.php b/app/Exceptions/GdprDeletionException.php new file mode 100644 index 00000000..5f93b090 --- /dev/null +++ b/app/Exceptions/GdprDeletionException.php @@ -0,0 +1,11 @@ +aboRepository->setModel($user_abo); $this->aboRepository->update($data); @@ -274,7 +275,7 @@ class AboController extends Controller ->orderColumn('status', 'status $1') ->orderColumn('active', 'active $1') ->orderColumn('is_for', 'is_for $1') - ->orderColumn('count', 'count $1') + ->orderColumn('count', '(select count(*) from user_abo_orders where user_abo_orders.user_abo_id = user_abos.id and user_abo_orders.status >= 2) $1') ->orderColumn('amount', 'amount $1') ->orderColumn('payone_userid', 'payone_userid $1') ->rawColumns(['id', 'status', 'active', 'is_for', 'member']) diff --git a/app/Http/Controllers/AdminUserController.php b/app/Http/Controllers/AdminUserController.php index 64e0ad3a..991800c0 100644 --- a/app/Http/Controllers/AdminUserController.php +++ b/app/Http/Controllers/AdminUserController.php @@ -2,21 +2,23 @@ namespace App\Http\Controllers; - - -use Auth; -use Carbon; -use Request; -use App\User; -use Validator; +use App\Exceptions\GdprDeletionException; +use App\Mail\UserRestoreEmail; +use App\Models\UserAccount; +use App\Repositories\UserRepository; +use App\Services\GdprDeletionService; +use App\Services\HTMLHelper; use App\Services\SysLog; use App\Services\UserUtil; -use App\Models\UserAccount; -use App\Services\HTMLHelper; -use App\Models\PaymentMethod; -use App\Repositories\UserRepository; +use App\User; +use Auth; +use Carbon; +use Illuminate\Contracts\View\Factory; +use Illuminate\Http\RedirectResponse; +use Illuminate\Routing\Redirector; use Illuminate\Support\Facades\Mail; -use Util; +use Illuminate\View\View; +use Request; class AdminUserController extends Controller { @@ -29,33 +31,35 @@ class AdminUserController extends Controller } /** - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * @return Factory|View */ public function index() { $data = [ - //'values' => User::where('admin', 0)->get(), - 'values' => User::where('confirmation_code_remider', '!=', 2)->get(), + // 'values' => User::where('admin', 0)->get(), + 'values' => User::where('confirmation_code_remider', '!=', 2)->get(), ]; + return view('admin.user.index', $data); } public function edit($user_id) { $user = User::findOrFail($user_id); - if (!$user->account) { - $user->account = new UserAccount(); + if (! $user->account) { + $user->account = new UserAccount; } $data = [ 'user' => $user, ]; + return view('admin.user.edit', $data); } /** - * @param Request $request - * @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View + * @param Request $request + * @return Factory|RedirectResponse|Redirector|View */ public function store() { @@ -73,7 +77,7 @@ class AdminUserController extends Controller SysLog::action('save-admin', 'admin_user', 3) ->setUserId(Auth::user()->id) ->setModel($user->id, User::class) - ->setMessage('Set user admin value: ' . HTMLHelper::getLabel($user->admin)) + ->setMessage('Set user admin value: '.HTMLHelper::getLabel($user->admin)) ->save(); } @@ -81,10 +85,10 @@ class AdminUserController extends Controller $data['confirmed'] = isset($data['confirmed']) ? true : false; $user->confirmed = $data['confirmed']; if ($data['confirmed']) { - if (!isset($data['confirmation_date']) || $data['confirmation_date'] == "") { + if (! isset($data['confirmation_date']) || $data['confirmation_date'] == '') { $user->confirmation_date = now(); } else { - $user->confirmation_date = \Carbon::parse(str_replace("- ", "", $data['confirmation_date'])); + $user->confirmation_date = Carbon::parse(str_replace('- ', '', $data['confirmation_date'])); } } else { $user->confirmation_date = null; @@ -92,7 +96,7 @@ class AdminUserController extends Controller SysLog::action('save-confirmed', 'admin_user', 3) ->setUserId(Auth::user()->id) ->setModel($user->id, User::class) - ->setMessage('Set user confirmed value: ' . $user->confirmed . " to date: " . $data['confirmation_date']) + ->setMessage('Set user confirmed value: '.$user->confirmed.' to date: '.$data['confirmation_date']) ->save(); } @@ -103,10 +107,10 @@ class AdminUserController extends Controller $user->wizard = 20; } if ($data['active']) { - if (!isset($data['active_date']) || $data['active_date'] == "") { + if (! isset($data['active_date']) || $data['active_date'] == '') { $user->active_date = now(); } else { - $user->active_date = \Carbon::parse(str_replace("- ", "", $data['active_date'])); + $user->active_date = Carbon::parse(str_replace('- ', '', $data['active_date'])); } } else { $user->active_date = null; @@ -114,7 +118,7 @@ class AdminUserController extends Controller SysLog::action('save-active', 'admin_user', 3) ->setUserId(Auth::user()->id) ->setModel($user->id, User::class) - ->setMessage('Set user active value: ' . $user->active . " to date: " . $data['active_date']) + ->setMessage('Set user active value: '.$user->active.' to date: '.$data['active_date']) ->save(); } @@ -122,14 +126,15 @@ class AdminUserController extends Controller $restore_account = isset($data['restore_account']) ? true : false; $restore_childs = isset($data['restore_childs']) ? true : false; $restore_send_email = isset($data['restore_send_email']) ? true : false; - if (isset($data['payment_account']) || $data['payment_account'] != "") { + if (isset($data['payment_account']) || $data['payment_account'] != '') { $error = UserUtil::checkEmailExists($user); if ($error) { \Session()->flash('alert-error', $error); + return redirect()->back()->withInput()->withErrors(['error' => $error]); } - $payment_account = \Carbon::parse(str_replace("- ", "", $data['payment_account'])); + $payment_account = Carbon::parse(str_replace('- ', '', $data['payment_account'])); if ($restore_account) { UserUtil::restoreUser($user, $payment_account); if ($restore_childs) { @@ -137,24 +142,24 @@ class AdminUserController extends Controller } if ($restore_send_email) { $user = User::with('account')->findOrFail($data['id']); - Mail::to($user->email)->send(new \App\Mail\UserRestoreEmail($user)); + Mail::to($user->email)->send(new UserRestoreEmail($user)); } } } SysLog::action('save-restore', 'admin_user', 3) ->setUserId(Auth::user()->id) ->setModel($user->id, User::class) - ->setMessage('Set user restore_account value: ' . $user->restore_account . " and restore_childs value: " . $user->restore_childs) + ->setMessage('Set user restore_account value: '.$user->restore_account.' and restore_childs value: '.$user->restore_childs) ->save(); } if (isset($data['save-account'])) { $old = $user->getPaymentAccountDateFormat(true); - if (!isset($data['payment_account']) || $data['payment_account'] == "") { + if (! isset($data['payment_account']) || $data['payment_account'] == '') { $user->payment_account = null; } else { $user->wizard = 100; - $payment_account = \Carbon::parse(str_replace("- ", "", $data['payment_account'])); + $payment_account = Carbon::parse(str_replace('- ', '', $data['payment_account'])); $user->payment_account = $payment_account; if ($payment_account > Carbon::now()) { if ($user->active === 0) { @@ -168,26 +173,26 @@ class AdminUserController extends Controller } } } - //th.schifferegger@gmail.com + // th.schifferegger@gmail.com SysLog::action('save-account', 'admin_user', 3) ->setUserId(Auth::user()->id) ->setModel($user->id, User::class) - ->setMessage('Set user payment_account from date: ' . $old . " to date: " . $data['payment_account']) + ->setMessage('Set user payment_account from date: '.$old.' to date: '.$data['payment_account']) ->save(); } if (isset($data['save-shop'])) { $old = $user->getPaymentShopDateFormat(true); - if (!isset($data['payment_shop']) || $data['payment_shop'] == "") { + if (! isset($data['payment_shop']) || $data['payment_shop'] == '') { $user->payment_shop = null; } else { $user->wizard = 100; - $user->payment_shop = \Carbon::parse(str_replace("- ", "", $data['payment_shop'])); + $user->payment_shop = Carbon::parse(str_replace('- ', '', $data['payment_shop'])); } SysLog::action('save-shop', 'admin_user', 3) ->setUserId(Auth::user()->id) ->setModel($user->id, User::class) - ->setMessage('Set user payment_shop from date: ' . $old . " to date: " . $data['payment_shop']) + ->setMessage('Set user payment_shop from date: '.$old.' to date: '.$data['payment_shop']) ->save(); } @@ -196,7 +201,7 @@ class AdminUserController extends Controller SysLog::action('save-test_mode', 'admin_user', 3) ->setUserId(Auth::user()->id) ->setModel($user->id, User::class) - ->setMessage('Set user test_mode value: ' . $user->test_mode) + ->setMessage('Set user test_mode value: '.$user->test_mode) ->save(); } @@ -205,13 +210,14 @@ class AdminUserController extends Controller SysLog::action('save-payment_methods', 'admin_user', 3) ->setUserId(Auth::user()->id) ->setModel($user->id, User::class) - ->setMessage('Set user payment_methods value: ' . $user->getPaymentMethodsShort()) + ->setMessage('Set user payment_methods value: '.$user->getPaymentMethodsShort()) ->save(); } $user->save(); \Session()->flash('alert-save', true); + return redirect('/admin/users'); } @@ -228,15 +234,24 @@ class AdminUserController extends Controller $this->userRepo->deleteUser($user, true); \Session()->flash('alert-success', __('msg.contact_delete')); } + if (isset($data['gdpr_delete_user'])) { + try { + app(GdprDeletionService::class)->deleteUser($user, Auth::id()); + \Session()->flash('alert-success', __('msg.gdpr_delete_success')); + } catch (GdprDeletionException $e) { + \Session()->flash('alert-danger', $e->getMessage()); + } + } + return redirect('/admin/users'); } - public function userLoginAs($userId) { if (Auth::user()->isSuperAdmin()) { $user = User::find($userId); Auth::login($user); + return redirect('/home'); } } @@ -253,7 +268,7 @@ class AdminUserController extends Controller }) ->with('account') ->select('users.*') - ->where('users.admin', "<", 5); + ->where('users.admin', '<', 5); return \DataTables::eloquent($query) ->addColumn('first_name', function (User $user) { @@ -261,89 +276,101 @@ class AdminUserController extends Controller }) ->addColumn('email', function (User $user) { if ($user->pre_deleted_at) { - return '' . $user->email . ''; + return ''.$user->email.''; } + return $user->email; }) ->addColumn('last_name', function (User $user) { return $user->account ? $user->account->last_name : ''; }) ->addColumn('id', function (User $user) { - return ''; + return ''; }) ->addColumn('admin', function (User $user) { - return '' . HTMLHelper::getRoleLabel($user->admin) . ''; + return ''.HTMLHelper::getRoleLabel($user->admin).''; }) ->addColumn('confirmed', function (User $user) { $date = $user->getConfirmationDateFormat(); - $link = ''; - return $user->confirmed ? $link . ' ' . $date . '' : $link . ''; + $link = ''; + + return $user->confirmed ? $link.' '.$date.'' : $link.''; }) ->addColumn('active', function (User $user) { if ($user->trashed()) { $date = Carbon::parse(now())->addDays(10)->format('d.m.Y H:i'); - $email = str_replace("delete-", "", $user->email); - $link = ''; - return $link . ' Account reaktivieren'; + $email = str_replace('delete-', '', $user->email); + $link = ''; + + return $link.' Account reaktivieren'; } $date = $user->getActiveDateFormat(); - $link = ''; - return $user->active ? $link . ' ' . $date . '' : $link . ''; + $link = ''; + + return $user->active ? $link.' '.$date.'' : $link.''; }) ->addColumn('account', function (User $user) { $date = $user->getPaymentAccountDateFormat(); - $link = ''; + $link = ''; if ($user->payment_account) { if ($user->isActiveAccount()) { - return $link . ' ' . $date . ''; + return $link.' '.$date.''; } - return $link . ' ' . $date . ''; + + return $link.' '.$date.''; } - return $link . ''; + + return $link.''; }) ->addColumn('shop', function (User $user) { $date = $user->getPaymentShopDateFormat(); - $link = ''; + $link = ''; if ($user->payment_shop) { if ($user->isActiveShop()) { - return $link . ' ' . $date . ''; + return $link.' '.$date.''; } - return $link . ' ' . $date . ''; + + return $link.' '.$date.''; } - return $link . ''; + + return $link.''; }) ->addColumn('shop_domain', function (User $user) { - return $user->shop ? '' . $user->shop->getSubdomain(false) . '' : ''; + return $user->shop ? ''.$user->shop->getSubdomain(false).'' : ''; }) ->addColumn('since', function (User $user) { if ($user->shop) { if ($user->shop->active) { return $user->shop->getActiveDateFormatSmall(); } + return $user->shop->getActiveDateFormatSmall(); } - return "-"; + + return '-'; }) ->addColumn('country', function (User $user) { return ($user->account && $user->account->country) ? $user->account->country->de : ''; }) ->addColumn('my_payment_methods', function (User $user) { $payment_methods = json_encode($user->payment_methods); - $link = ''; - if (!$user->payment_methods) { - return $link . ''; + $link = ''; + if (! $user->payment_methods) { + return $link.''; } - return $link . ' ' . $user->getPaymentMethodsShort() . ''; + + return $link.' '.$user->getPaymentMethodsShort().''; }) ->addColumn('action_login', function (User $user) { - return ''; + return ''; }) ->addColumn('action_delete', function (User $user) { - return ''; + return ''; }) ->addColumn('test_mode', function (User $user) { - $link = ''; - return $user->test_mode ? $link . '' : $link . ''; + $link = ''; + + return $user->test_mode ? $link.'' : $link.''; }) ->orderColumn('id', 'id $1') ->orderColumn('email', 'email $1') diff --git a/app/Http/Controllers/Api/KasController.php b/app/Http/Controllers/Api/KasController.php deleted file mode 100644 index 1a94b8b6..00000000 --- a/app/Http/Controllers/Api/KasController.php +++ /dev/null @@ -1,116 +0,0 @@ - Sekunden ab, bei Y verlängert sich die Session mit jeder Benutzung - private $CredentialToken = false; - private $kas_flood_delay = 2; - - /** - * Create a new controller instance. - * - * @return void - */ - public function __construct() - { - $this->login(); - } - - - public function action($func, $para = array()){ - - $this->checkSession($func); - try - { - $Params = array(); // Parameter für die API-Funktion - $SoapRequest = new SoapClient('https://kasapi.kasserver.com/soap/wsdl/KasApi.wsdl', [ - 'cache_wsdl' => WSDL_CACHE_NONE, - 'exceptions' => false - ]); - $req = $SoapRequest->KasApi(json_encode(array( - 'KasUser' => $this->kas_user, // KAS-User - 'KasAuthType' => 'session', // Auth per Sessiontoken - 'KasAuthData' => $this->CredentialToken, // Auth-Token - 'KasRequestType' => $func, // API-Funktion - 'KasRequestParams' => $para // Parameter an die API-Funktion - ))); - Session::put('flood_protection.'.$func, time() + $this->kas_flood_delay + 0.2); - - if(is_array($req) && isset($req['Response']['ReturnString']) && $req['Response']['ReturnString'] == "TRUE"){ - return $req['Response']['ReturnInfo']; - } - return $req; - } - - // Fehler abfangen und ausgeben - catch (\SoapFault $fault) - { - trigger_error(" Fehlernummer: {$fault->faultcode}, - Fehlermeldung: {$fault->faultstring}, - Verursacher: {$fault->faultactor}, - Details: {$fault->detail}", E_USER_ERROR); - } - } - - - private function login(){ - - $this->checkSession('auth'); - try - { - - $SoapLogon = new SoapClient('https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl', [ - 'cache_wsdl' => WSDL_CACHE_NONE, - 'exceptions' => false - ]); - $this->CredentialToken = $SoapLogon->KasAuth(json_encode(array( - 'KasUser' => $this->kas_user, - 'KasAuthType' => 'plain', - 'KasPassword' => $this->kas_pass, - 'SessionLifeTime' => $this->session_lifetime, - 'SessionUpdateLifeTime' => $this->session_update_lifetime - ))); - Session::put('flood_protection.auth', time() + $this->kas_flood_delay + 0.2); - - } - - // Fehler abfangen und ausgeben - catch (\SoapFault $fault) - { - trigger_error("Fehlernummer: {$fault->faultcode}, - Fehlermeldung: {$fault->faultstring}, - Verursacher: {$fault->faultactor}, - Details: {$fault->detail}", E_USER_ERROR); - } - - } - - private function checkSession($func) - { - $name = 'flood_protection.'.$func; - - if(Session::exists($name)){ - $time_to_wait = (float)Session::get($name) - time(); - Session::forget($name); - }else { - $time_to_wait = 0; - } - if ( $time_to_wait >= 0 ) { - usleep( intval( $time_to_wait*1000000 ) ); - } - - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/ShoppingUserController.php b/app/Http/Controllers/Api/ShoppingUserController.php index 1e98dbc7..f3cf82bf 100644 --- a/app/Http/Controllers/Api/ShoppingUserController.php +++ b/app/Http/Controllers/Api/ShoppingUserController.php @@ -11,7 +11,9 @@ use App\Models\ShoppingOrder; use App\Models\ShoppingOrderItem; use App\Models\ShoppingUser; use App\Services\CustomerPriority; +use App\Services\Shop; use Carbon\Carbon; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Mail; use Yard; @@ -28,7 +30,7 @@ class ShoppingUserController extends Controller /** * @param Request $request * wp_order_numbers[1234, 1234] - * @return \Illuminate\Http\JsonResponse + * @return JsonResponse */ public function status(Request $request) { @@ -74,7 +76,7 @@ class ShoppingUserController extends Controller /** * @param Request $request * wp_order_number [1234] - * @return \Illuminate\Http\JsonResponse + * @return JsonResponse */ public function cancel(Request $request) { @@ -126,7 +128,7 @@ class ShoppingUserController extends Controller /** * @param Request $request * wp_order_number [1234] - * @return \Illuminate\Http\JsonResponse + * @return JsonResponse */ public function open(Request $request) { @@ -178,7 +180,7 @@ class ShoppingUserController extends Controller /** * @param Request $request * wp_order_numbers [1234, 1234] - * @return \Illuminate\Http\JsonResponse + * @return JsonResponse */ public function show(Request $request) { @@ -229,7 +231,7 @@ class ShoppingUserController extends Controller } /** - * @return \Illuminate\Http\JsonResponse + * @return JsonResponse */ public function store(Request $request) { @@ -258,7 +260,7 @@ class ShoppingUserController extends Controller // Kundenhoheit prüfen $priority = CustomerPriority::checkOne($shopping_user, true, false, true); - \App\Services\Shop::newUserOrder($shopping_user->number); + Shop::newUserOrder($shopping_user->number); // exists //like //update $user = $this->prepareForShow($shopping_user); @@ -277,7 +279,7 @@ class ShoppingUserController extends Controller } /** - * @return \Illuminate\Http\JsonResponse + * @return JsonResponse */ public function update(Request $request) { @@ -297,7 +299,7 @@ class ShoppingUserController extends Controller // Kundenhoheit prüfen $priority = CustomerPriority::checkChangeOne($shopping_user, $data, true); $updated = $shopping_user->fill($data)->save(); - \App\Services\Shop::newUserOrder($shopping_user->number); + Shop::newUserOrder($shopping_user->number); if ($updated) { $user = $this->prepareForShow($shopping_user); @@ -610,6 +612,12 @@ class ShoppingUserController extends Controller $ShippingCountry = ShippingCountry::whereCountryId($shopping_user->shipping_country_id)->first(); if ($ShippingCountry) { + Yard::instance('shopping')->setUserPriceInfos([ + 'shipping_free' => $ShippingCountry->shipping?->free ?? false, + 'user_tax_free' => false, + 'user_reverse_charge' => false, + 'user_country_id' => $shopping_user->billing_country_id, + ]); Yard::instance('shopping')->setShippingCountryWithPrice($ShippingCountry->id); } $shopping_order = $this->makeShoppingOrder($shopping_user, $wp_invoice_path, $api_notice); diff --git a/app/Http/Controllers/CustomerController.php b/app/Http/Controllers/CustomerController.php index eb673b0d..33a54217 100644 --- a/app/Http/Controllers/CustomerController.php +++ b/app/Http/Controllers/CustomerController.php @@ -2,14 +2,17 @@ namespace App\Http\Controllers; +use App\Exceptions\GdprDeletionException; use App\Models\ShoppingUser; use App\Repositories\CustomerRepository; use App\Services\CustomerPriority; +use App\Services\GdprDeletionService; use App\Services\HTMLHelper; +use App\Services\Shop; +use Auth; use Request; use Validator; - class CustomerController extends Controller { protected $customerRepository; @@ -17,6 +20,7 @@ class CustomerController extends Controller public function __construct(CustomerRepository $customerRepository) { $this->middleware('admin'); + $this->middleware('superadmin')->only('gdprDelete'); $this->customerRepository = $customerRepository; } @@ -25,16 +29,17 @@ class CustomerController extends Controller if (Request::get('reset') === 'filter') { set_user_attr('filter_member_id', null); set_user_attr('filter_customer_member', null); + return redirect(route('admin_customers')); } - $filter_members = ShoppingUser::join('users', 'member_id', '=', 'users.id')->groupBy('member_id')->join('user_accounts', 'account_id', '=', 'user_accounts.id')->select('users.id', 'users.email', 'user_accounts.first_name', 'user_accounts.last_name')->get(); //->pluck('email', 'id')->unique()->toArray(); + $filter_members = ShoppingUser::join('users', 'member_id', '=', 'users.id')->groupBy('member_id')->join('user_accounts', 'account_id', '=', 'user_accounts.id')->select('users.id', 'users.email', 'user_accounts.first_name', 'user_accounts.last_name')->get(); // ->pluck('email', 'id')->unique()->toArray(); $data = [ 'filter_members' => $filter_members, ]; + return view('admin.customer.index', $data); } - public function detail($id) { $shopping_user = ShoppingUser::findOrFail($id); @@ -43,6 +48,7 @@ class CustomerController extends Controller 'isAdmin' => true, 'isView' => 'customer', ]; + return view('admin.customer.detail', $data); } @@ -50,20 +56,44 @@ class CustomerController extends Controller { $shopping_user = ShoppingUser::findOrFail($id); $result = $this->customerRepository->deleteCustomer($shopping_user); - if (!$result) { + if (! $result) { return back()->with('alert-error', 'Kunde hat Bestellungen. Löschen nicht möglich.'); } else { \Session()->flash('alert-success', 'Kunde wurde gelöscht'); + return redirect(route('admin_customers')); } + + return redirect(route('admin_customers')); + } + + public function gdprDelete($id) + { + $shopping_user = ShoppingUser::withTrashed()->findOrFail($id); + + if (! Request::get('gdpr_delete_customer')) { + \Session()->flash('alert-danger', __('msg.gdpr_confirm_checkbox_missing')); + + return redirect(route('admin_customer_detail', [$shopping_user->id])); + } + + try { + app(GdprDeletionService::class)->deleteShoppingUser($shopping_user, Auth::id()); + \Session()->flash('alert-success', __('msg.gdpr_delete_customer_success')); + } catch (GdprDeletionException $e) { + \Session()->flash('alert-danger', $e->getMessage()); + + return redirect(route('admin_customer_detail', [$shopping_user->id])); + } + return redirect(route('admin_customers')); } public function edit($id) { - if ($id === "new") { - $shopping_user = new ShoppingUser(); - $shopping_user->id = "new"; + if ($id === 'new') { + $shopping_user = new ShoppingUser; + $shopping_user->id = 'new'; } else { $shopping_user = ShoppingUser::findOrFail($id); } @@ -72,6 +102,7 @@ class CustomerController extends Controller 'isAdmin' => true, 'isView' => 'customer', ]; + return view('admin.customer.edit', $data); } @@ -80,23 +111,25 @@ class CustomerController extends Controller $data = Request::all(); if ($data['action'] === 'shopping-user-change-member') { - if (!isset($data['change_member_key']) || $data['change_member_key'] !== config('mivita.edit_data_pass')) { + if (! isset($data['change_member_key']) || $data['change_member_key'] !== config('mivita.edit_data_pass')) { $data = [ - 'change_member_error' => "Das Passwort ist falsch.", + 'change_member_error' => 'Das Passwort ist falsch.', 'shopping_user' => ShoppingUser::find($id), 'isAdmin' => true, 'isView' => 'customer', ]; + return view('admin.customer.detail', $data); } - //change + // change $shopping_user = ShoppingUser::findOrFail($data['id']); CustomerPriority::newMemberForCustomer($shopping_user, $data['change_member_id'], $data['customer_set_member_for']); \Session()->flash('alert-save', true); + return redirect(route('admin_customer_detail', [$shopping_user->id])); } if ($data['action'] === 'shopping-user-store') { - $rules = array( + $rules = [ 'billing_salutation' => 'required', 'billing_firstname' => 'required', 'billing_lastname' => 'required', @@ -104,10 +137,10 @@ class CustomerController extends Controller 'billing_address' => 'required', 'billing_zipcode' => 'required', 'billing_city' => 'required', - 'billing_country_id' => 'required' - ); + 'billing_country_id' => 'required', + ]; - if (!Request::get('same_as_billing')) { + if (! Request::get('same_as_billing')) { $rules = array_merge($rules, [ 'shipping_firstname' => 'required', 'shipping_lastname' => 'required', @@ -115,7 +148,7 @@ class CustomerController extends Controller 'shipping_zipcode' => 'required', 'shipping_city' => 'required', 'shipping_salutation' => 'required', - 'shipping_country_id' => 'required' + 'shipping_country_id' => 'required', ]); } $validator = Validator::make(Request::all(), $rules); @@ -128,7 +161,7 @@ class CustomerController extends Controller $data['language'] = isset($data['language']) ? $data['language'] : \App::getLocale(); $data['has_buyed'] = isset($data['has_buyed']) ? true : false; $data['subscribed'] = isset($data['subscribed']) ? true : false; - //subscribed can only true when has_buyed ist active + // subscribed can only true when has_buyed ist active $data['subscribed'] = $data['has_buyed'] ? $data['subscribed'] : false; /* if($shopping_user->auth_user_id > 0){ @@ -140,21 +173,23 @@ class CustomerController extends Controller CustomerPriority::checkChangeOne($shopping_user, $data, true); $shopping_user->fill($data); $shopping_user->save(); - \App\Services\Shop::newUserOrder($shopping_user->number); + Shop::newUserOrder($shopping_user->number); \Session()->flash('alert-save', true); } + return redirect(route('admin_customer_detail', [$shopping_user->id])); } public function getCustomers() { - $query = ShoppingUser::select('shopping_users.*')->where('auth_user_id', '=', NULL); + $query = ShoppingUser::select('shopping_users.*')->where('auth_user_id', '=', null); set_user_attr('filter_member_id', Request::get('filter_member_id')); - if (Request::get('filter_member_id') != "") { + if (Request::get('filter_member_id') != '') { $query->where('member_id', '=', Request::get('filter_member_id')); } + /* set_user_attr('filter_customer_member', Request::get('filter_customer_member')); if(Request::get('filter_customer_member') != ""){ if(Request::get('filter_customer_member') === 'customers'){ @@ -166,10 +201,10 @@ class CustomerController extends Controller }*/ return \DataTables::eloquent($query) ->addColumn('id', function (ShoppingUser $ShoppingUser) { - return ''; + return ''; }) ->addColumn('billing_email', function (ShoppingUser $ShoppingUser) { - return $ShoppingUser->faker_mail ? "-" : $ShoppingUser->billing_email; + return $ShoppingUser->faker_mail ? '-' : $ShoppingUser->billing_email; }) ->addColumn('billing_salutation', function (ShoppingUser $ShoppingUser) { return HTMLHelper::getSalutationLang($ShoppingUser->billing_salutation); @@ -178,19 +213,19 @@ class CustomerController extends Controller return $ShoppingUser->billing_country ? $ShoppingUser->billing_country->getLocated() : ''; }) ->addColumn('isMember', function (ShoppingUser $ShoppingUser) { - return get_active_badge($ShoppingUser->auth_user_id) . ($ShoppingUser->mode === 'dev' ? ' dev' : ''); + return get_active_badge($ShoppingUser->auth_user_id).($ShoppingUser->mode === 'dev' ? ' dev' : ''); }) ->addColumn('member_id', function (ShoppingUser $ShoppingUser) { if ($ShoppingUser->is_like) { return ''; + data-route="'.route('modal_load').'"> Berater zuordnen'; } if ($ShoppingUser->member) { - return '' . $ShoppingUser->member->getFullName() . ''; + return ''.$ShoppingUser->member->getFullName().''; } return ''; @@ -202,8 +237,8 @@ class CustomerController extends Controller return get_active_badge($ShoppingUser->subscribed); }) ->filterColumn('billing_email', function ($query, $keyword) { - if ($keyword != "") { - $query->where('billing_email', 'LIKE', '%' . $keyword . '%'); + if ($keyword != '') { + $query->where('billing_email', 'LIKE', '%'.$keyword.'%'); } }) ->orderColumn('id', 'id $1') diff --git a/app/Http/Controllers/FileController.php b/app/Http/Controllers/FileController.php index 6a4ba265..1d76fa67 100644 --- a/app/Http/Controllers/FileController.php +++ b/app/Http/Controllers/FileController.php @@ -2,7 +2,11 @@ namespace App\Http\Controllers; +use App\Models\DcFile; +use App\Models\File; +use App\Models\ShoppingOrder; use App\Models\UserCredit; +use App\Models\UserInvoice; use App\Repositories\CreditRepository; use Auth; use Response; @@ -78,7 +82,7 @@ class FileController extends Controller } }*/ if ($from === 'invoice') { - $shopping_order = \App\Models\ShoppingOrder::findOrFail($id); + $shopping_order = ShoppingOrder::findOrFail($id); if ($shopping_order->user_invoice) { $this->isPermissionShoppingOrder($shopping_order); $user_invoice = $shopping_order->user_invoice; @@ -95,7 +99,7 @@ class FileController extends Controller } if ($from === 'delivery') { - $shopping_order = \App\Models\ShoppingOrder::findOrFail($id); + $shopping_order = ShoppingOrder::findOrFail($id); if ($shopping_order->user_invoice) { $this->isPermissionShoppingOrder($shopping_order); $user_invoice = $shopping_order->user_invoice; @@ -121,12 +125,12 @@ class FileController extends Controller } if ($from === 'cancellation') { - $shopping_order = \App\Models\ShoppingOrder::findOrFail($id); + $shopping_order = ShoppingOrder::findOrFail($id); $this->isPermissionShoppingOrder($shopping_order); // Stornorechnung finden: cancellation=true UND cancellation_id=null // (Die Original-Rechnung hat auch cancellation=true, aber MIT cancellation_id) - $cancellation_invoice = \App\Models\UserInvoice::where('shopping_order_id', $shopping_order->id) + $cancellation_invoice = UserInvoice::where('shopping_order_id', $shopping_order->id) ->where('cancellation', true) ->whereNull('cancellation_id') ->first(); @@ -147,7 +151,7 @@ class FileController extends Controller } if ($from === 'credit') { - $user_credit = \App\Models\UserCredit::findOrFail($id); + $user_credit = UserCredit::findOrFail($id); $this->isPermissionUserCredit($user_credit); $disk = $user_credit->disk; // Lokalisierte Version wenn angegeben @@ -161,7 +165,7 @@ class FileController extends Controller } if ($from === 'credit_detail') { - $user_credit = \App\Models\UserCredit::findOrFail($id); + $user_credit = UserCredit::findOrFail($id); $this->isPermissionUserCredit($user_credit); return $this->create_credit_detail($user_credit, $do); @@ -175,14 +179,14 @@ class FileController extends Controller if ($from === 'dc_file') { // $this->isPermissionAuth(); - $dc_file = \App\Models\DcFile::findOrFail($id); + $dc_file = DcFile::findOrFail($id); $filename = $dc_file->filename; $disk = 'public'; $path = $dc_file->getFile(); } if ($from === 'dc_thumb') { // $this->isPermissionAuth(); - $dc_file = \App\Models\DcFile::findOrFail($id); + $dc_file = DcFile::findOrFail($id); $filename = $dc_file->filename; $disk = 'public'; $path = $dc_file->getThumb(); @@ -190,14 +194,14 @@ class FileController extends Controller if ($from === 'dc_big') { // $this->isPermissionAuth(); - $dc_file = \App\Models\DcFile::findOrFail($id); + $dc_file = DcFile::findOrFail($id); $filename = $dc_file->filename; $disk = 'public'; $path = $dc_file->getBig(); } if ($from === 'user') { - $file = \App\Models\File::findOrFail($id); + $file = File::findOrFail($id); $filename = $file->filename; $disk = 'user'; $path = $file->dir.$file->filename; @@ -244,6 +248,7 @@ class FileController extends Controller private function create_credit_detail(UserCredit $user_credit, $do) { + abort_if($user_credit->user === null, 404, 'User zur Gutschrift nicht gefunden.'); $credit_repo = new CreditRepository($user_credit->user); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index b785db8b..0f319e32 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -7,15 +7,15 @@ use App\Models\Incentive; use App\Models\IncentiveParticipant; use App\Models\ShoppingPayment; use App\Models\UserAbo; -use App\Models\UserBusiness; +use App\Services\Abo\PaylineForecastService; use App\Services\Abo\TeamAboOverviewService; use App\Services\AboHelper; use App\Services\Backoffice\BackofficeDashboardService; -use App\Services\BusinessPlan\TreeCalcBot; +use App\Services\BusinessPlan\LiveCommissionService; use App\User; use Carbon\Carbon; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; +use Illuminate\View\View; use Request; use Util; @@ -38,11 +38,11 @@ class HomeController extends Controller return redirect('home'); } - public function newsArchive(): \Illuminate\View\View + public function newsArchive(): View { return view('dashboard.news_archive', [ - 'currentNews' => \App\Models\DashboardNews::getActiveNews(), - 'archiveNews' => \App\Models\DashboardNews::getArchiveNews(), + 'currentNews' => DashboardNews::getActiveNews(), + 'archiveNews' => DashboardNews::getArchiveNews(), ]); } @@ -111,10 +111,18 @@ class HomeController extends Controller private function buildMonthlyStats(User $user): array { $month = max(1, min(12, (int) Request::get('stats_month', now()->month))); - $year = (int) Request::get('stats_year', now()->year); + $year = max(2020, min((int) now()->year + 1, (int) Request::get('stats_year', now()->year))); $salesVolume = $user->getUserSalesVolume($month, $year, 'first'); [$paylinePoints, $paylineIsLive] = $this->teamPaylinePoints($user, $month, $year); + $customerPoints = $salesVolume ? (float) $salesVolume->getPointsKPSum() : 0.0; + + // Payline-Prognose (B10) nur für den laufenden Monat — Basis ist der heutige + // Stand aus Kundenumsatz + Payline-Punkten. + $isCurrentMonth = $month === (int) now()->month && $year === (int) now()->year; + $paylineForecast = $isCurrentMonth + ? app(PaylineForecastService::class)->forecastDays($user, $customerPoints + (float) $paylinePoints) + : null; $firstlineIds = User::where('m_sponsor', $user->id) ->whereColumn('id', '!=', 'm_sponsor') @@ -130,9 +138,11 @@ class HomeController extends Controller [ 'month' => $month, 'year' => $year, - 'customer_points' => $salesVolume ? (float) $salesVolume->getPointsKPSum() : 0.0, + 'customer_points' => $customerPoints, 'team_payline_points' => $paylinePoints, 'payline_is_live' => $paylineIsLive, + 'payline_forecast' => $paylineForecast, + 'commission' => app(LiveCommissionService::class)->commission($user, $month, $year), 'direct_new_partners' => $dashboardService->newPartnersCount($firstlineIds, $month, $year), ], $teamOverview->teamKpis($user, $month, $year), @@ -141,45 +151,16 @@ class HomeController extends Controller } /** - * Team-Payline-Punkte: aus dem UserBusiness-Snapshot (abgeschlossene Monate) oder - * – für den laufenden Monat – einmalig live berechnet und für Folge-Aufrufe gecacht. + * Team-Payline-Punkte: aus dem gemeinsamen Live-Lauf ({@see LiveCommissionService}), + * der auch die Provision und die Payline-Tiefe liefert — ein Baum-Lauf für alles. * * @return array{0: float, 1: bool} [Punkte, istLive] */ private function teamPaylinePoints(User $user, int $month, int $year): array { - $userBusiness = UserBusiness::where('user_id', $user->id) - ->where('month', $month) - ->where('year', $year) - ->first(); + $snapshot = app(LiveCommissionService::class)->snapshot($user, $month, $year); - if ($userBusiness) { - return [(float) ($userBusiness->payline_points ?? 0), false]; - } - - $points = Cache::remember( - "home_payline_{$user->id}_{$month}_{$year}", - now()->addHours(6), - fn (): float => $this->calculatePaylineLive($user, $month, $year) - ); - - return [(float) $points, true]; - } - - private function calculatePaylineLive(User $user, int $month, int $year): float - { - try { - $treeCalcBot = new TreeCalcBot($month, $year, 'member'); - $treeCalcBot->initBusinesslUserDetail($user); - - if ($treeCalcBot->business_user) { - return (float) ($treeCalcBot->business_user->payline_points ?? 0); - } - } catch (\Throwable $e) { - \Log::warning("Live-Berechnung Payline-Punkte fehlgeschlagen für User {$user->id}: ".$e->getMessage()); - } - - return 0.0; + return [$snapshot['payline_points'], $snapshot['is_live']]; } public function loadingModal() diff --git a/app/Http/Controllers/ModalController.php b/app/Http/Controllers/ModalController.php index eb61c17a..111ca563 100644 --- a/app/Http/Controllers/ModalController.php +++ b/app/Http/Controllers/ModalController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\Country; use App\Models\Homeparty; use App\Models\HomepartyUser; use App\Models\Product; @@ -11,10 +12,12 @@ use App\Models\UserAbo; use App\Models\UserCredit; use App\Models\UserLevel; use App\Models\UserSalesVolume; +use App\Services\Abo\TeamAboOverviewService; use App\Services\AboHelper; use App\Services\BusinessPlan\TreeCalcBot; use App\Services\BusinessPlan\TreeCalcBotOptimized; use App\Services\DhlModalService; +use App\Services\DhlProductResolver; use App\User; use Request; @@ -141,7 +144,7 @@ class ModalController extends Controller } if ($data['action'] === 'team-abo-member-show') { - $summary = app(\App\Services\Abo\TeamAboOverviewService::class) + $summary = app(TeamAboOverviewService::class) ->memberAboSummary(\Auth::user(), (int) $data['id']); if ($summary) { $ret = view('user.team.modal_member_abos', compact('summary', 'data'))->render(); @@ -174,7 +177,7 @@ class ModalController extends Controller $ret = view('admin.payment.modal_add_credit', compact('value', 'data'))->render(); } if ($data['action'] === 'user-credit-status') { - $UserCredit = UserCredit::find($data['id']); // current user form order + $UserCredit = UserCredit::findOrFail($data['id']); // current user form order $ret = view('admin.payment.modal_credit_status', compact('UserCredit', 'data'))->render(); } if ($data['action'] === 'abo_update_settings') { @@ -279,12 +282,12 @@ class ModalController extends Controller 'order' => null, 'orderWeight' => 1.0, 'shippingAddress' => null, - 'availableCountries' => \App\Models\Country::where('active', 1)->get(), + 'availableCountries' => Country::where('active', 1)->get(), 'productCodes' => [ 'V01PAK' => 'DHL Paket (National)', 'V53WPAK' => 'DHL Paket International', ], - 'productSuggestions' => (new \App\Services\DhlProductResolver)->getProductSuggestionsByCountry(), + 'productSuggestions' => (new DhlProductResolver)->getProductSuggestionsByCountry(), 'selectedProductCode' => 'V01PAK', 'errors' => ['Fehler beim Laden der Daten: '.$e->getMessage()], 'warnings' => [], diff --git a/app/Http/Controllers/Portal/AboController.php b/app/Http/Controllers/Portal/AboController.php index fd2f1aae..606575d0 100644 --- a/app/Http/Controllers/Portal/AboController.php +++ b/app/Http/Controllers/Portal/AboController.php @@ -57,8 +57,12 @@ class AboController extends Controller } $shopping_user = ShoppingUser::findOrFail($user->shopping_user_id); + // Gekündigte/beendete Abos (Status 4/5) zählen nicht mehr als "mein Abo" — + // nach einer Kündigung muss das Portal wieder die Neuanlage anbieten (Ein-Abo-Regel, A2). $user_abo = UserAbo::where('email', $shopping_user->billing_email) ->where('status', '>=', 1) + ->whereNotIn('status', AboHelper::ENDED_STATUSES) + ->orderByDesc('id') ->first(); if (! $user_abo) { @@ -110,6 +114,7 @@ class AboController extends Controller if ($data['action'] === 'abo_update_settings') { $user_abo = UserAbo::findOrFail($data['id']); $this->checkPortalPermission($user_abo); + $data['view'] = 'portal'; $aboRepository = new AboRepository; $aboRepository->setModel($user_abo); $aboRepository->update($data); @@ -243,6 +248,7 @@ class AboController extends Controller 'summary' => $summary, 'add_only_mode' => $isAddOnlyMode, 'change_locked' => $changeLocked, + 'portal_view' => true, ])->render(); $html_comp = view('user.order.comp_product', array_merge($data, [ 'cart_instance' => AboOrderCart::INSTANCE, @@ -250,6 +256,7 @@ class AboController extends Controller ]))->render(); $html_summary = view('admin.abo._order_combined_summary', [ 'summary' => $summary, + 'portal_view' => true, ])->render(); $amount = $user_abo->getFormattedAmount(); @@ -409,9 +416,11 @@ class AboController extends Controller 'only_show_products' => false, 'add_only_mode' => $isAddOnlyMode, 'change_locked' => $changeLocked, + 'portal_view' => true, ])->render(), 'html_summary' => view('admin.abo._order_combined_summary', [ 'summary' => $summary, + 'portal_view' => true, ])->render(), 'html_comp' => view('user.order.comp_product', [ 'comp_products' => [], diff --git a/app/Http/Controllers/SyS/SysController.php b/app/Http/Controllers/SyS/SysController.php index c52e6c1c..8eccf511 100644 --- a/app/Http/Controllers/SyS/SysController.php +++ b/app/Http/Controllers/SyS/SysController.php @@ -11,7 +11,6 @@ use App\Services\SyS\CleanHTMLProductDescription; use App\Services\SyS\Correction; use App\Services\SyS\Cronjobs; use App\Services\SyS\Customers; -use App\Services\SyS\DomainSSL; use App\Services\SyS\Import; use App\Services\SyS\ImportDbipCountry; use App\Services\SyS\PayoneCallbackTestbench; @@ -57,9 +56,6 @@ class SysController extends Controller case 'cronjobs': return Cronjobs::show(); break; - case 'domainssl': - return DomainSSL::show(); - break; case 'shopping_orders': return ShoppingOrders::show(); break; @@ -115,9 +111,6 @@ class SysController extends Controller case 'cronjobs': return Cronjobs::store(); break; - case 'domainssl': - return DomainSSL::store(); - break; case 'shopping_orders': return ShoppingOrders::store(); break; diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index cc5398a9..04c76d5c 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -158,7 +158,13 @@ class AboController extends Controller if (isset($data['action'])) { if ($data['action'] === 'abo_update_settings') { + // Body-id kann von der Route abweichen: Berechtigungen erneut auf dem + // tatsächlich bearbeiteten Abo prüfen. Die Sicht kommt ausschließlich aus + // der (bereits geprüften) Route, nie aus dem Formular — sonst ließen sich + // per view=admin sämtliche Selfservice-Regeln umgehen. $user_abo = UserAbo::findOrFail($data['id']); + $this->checkPermissions($view, $user_abo); + $data['view'] = $view; $this->aboRepository->setModel($user_abo); $this->aboRepository->update($data); diff --git a/app/Http/Controllers/User/BackofficeStatisticsController.php b/app/Http/Controllers/User/BackofficeStatisticsController.php index 256fa345..7b4432b1 100644 --- a/app/Http/Controllers/User/BackofficeStatisticsController.php +++ b/app/Http/Controllers/User/BackofficeStatisticsController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Services\Backoffice\BackofficeDashboardService; use App\Services\Backoffice\BackofficeDrilldownService; use App\Services\Backoffice\PersonalStatisticsService; +use App\Services\BusinessPlan\LiveCommissionService; use App\Services\HTMLHelper; use Illuminate\Http\Request; use Illuminate\View\View; @@ -47,6 +48,7 @@ class BackofficeStatisticsController extends Controller 'filterYears' => HTMLHelper::getYearRange(2022), 'statistics' => $statistics, 'performance' => $performance, + 'commission' => app(LiveCommissionService::class)->commission($request->user(), $selectedMonth, $selectedYear), ]); } @@ -76,11 +78,12 @@ class BackofficeStatisticsController extends Controller [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); $line = (int) $request->get('line', 1); $metric = (string) $request->get('metric', 'consultants'); + $onlyNew = $request->boolean('only_new'); return view('user.backoffice.statistics.details', [ 'selectedMonth' => $selectedMonth, 'selectedYear' => $selectedYear, - 'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear), + 'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew), ]); } @@ -93,10 +96,12 @@ class BackofficeStatisticsController extends Controller [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); $line = (int) $request->get('line', 1); $metric = (string) $request->get('metric', 'consultants'); - $details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear); + $onlyNew = $request->boolean('only_new'); + $details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear, $onlyNew); $filename = sprintf( - 'backoffice-statistik-%s-linie-%s-%02d-%d.csv', + 'backoffice-statistik-%s%s-linie-%s-%02d-%d.csv', $metric, + $details['only_new'] ? '-neu' : '', $line === 0 ? 'alle' : $line, $selectedMonth, $selectedYear @@ -156,7 +161,7 @@ class BackofficeStatisticsController extends Controller private function selectedPeriod(Request $request): array { $selectedMonth = max(1, min(12, (int) $request->get('month', session(self::SESSION_MONTH_KEY, now()->month)))); - $selectedYear = (int) $request->get('year', session(self::SESSION_YEAR_KEY, now()->year)); + $selectedYear = max(2020, min((int) now()->year + 1, (int) $request->get('year', session(self::SESSION_YEAR_KEY, now()->year)))); session([ self::SESSION_MONTH_KEY => $selectedMonth, @@ -232,6 +237,10 @@ class BackofficeStatisticsController extends Controller return ['Name', 'E-Mail', 'Karriere-Level', 'Eigenpunkte', 'Shop-Punkte', 'Abo-Punkte', 'Sonstige Punkte', 'Kundenpunkte', 'Gesamtpunkte']; } + if ($metric === 'new_partners') { + return ['Name', 'E-Mail', 'Karriere-Level', 'Sponsor', 'Kundenpunkte', 'Aktiv seit', 'Account gueltig bis']; + } + return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status']; } @@ -280,6 +289,18 @@ class BackofficeStatisticsController extends Controller ]; } + if ($metric === 'new_partners') { + return [ + $row['name'] ?? '', + $row['email'] ?? '', + $row['career_level'] ?? '', + $row['sponsor'] ?? '', + $row['customer_points'] ?? 0, + $row['active_date'] ?? '', + $row['payment_account'] ?? '', + ]; + } + return [ $row['name'] ?? '', $row['email'] ?? '', @@ -311,6 +332,10 @@ class BackofficeStatisticsController extends Controller return ['Summe', $summary['count'].' Eintraege', '', $summary['own_points'], $summary['customer_single_order_points'], $summary['customer_abo_points'], $summary['customer_other_points'], $summary['external_points'], $summary['total_points']]; } + if ($metric === 'new_partners') { + return ['Summe', $summary['count'].' Eintraege', '', '', $summary['customer_points'], '', '', '']; + } + return ['Summe', $summary['count'].' Eintraege', '', '', '', '']; } } diff --git a/app/Http/Controllers/User/TeamController.php b/app/Http/Controllers/User/TeamController.php index a6819570..3987a233 100644 --- a/app/Http/Controllers/User/TeamController.php +++ b/app/Http/Controllers/User/TeamController.php @@ -4,9 +4,14 @@ namespace App\Http\Controllers\User; use App\Exports\UserTeamExport; use App\Http\Controllers\Controller; +use App\Models\UserAbo; use App\Models\UserBusiness; +use App\Models\UserLevel; use App\Models\UserSalesVolume; +use App\Services\Abo\PaylineForecastService; +use App\Services\Abo\TeamAboOverviewService; use App\Services\AboHelper; +use App\Services\AboOrderCart; use App\Services\BusinessPlan\ExportBot; use App\Services\BusinessPlan\TreeCalcBot; use App\Services\BusinessPlan\TreeCalcBotOptimized; @@ -16,6 +21,7 @@ use App\Services\LevelReportService; use App\Services\NextLevelBadgeHelper; use App\Services\TranslationHelper; use App\User; +use Illuminate\Http\RedirectResponse; use Maatwebsite\Excel\Facades\Excel; use Request; @@ -318,6 +324,9 @@ class TeamController extends Controller }) ->addColumn('is_qual_kp', function ($teamUser) { $user = User::find($teamUser->user_id); + if ($user === null) { + return '-'; + } return TreeHelperOptimized::generateQualKPBadgeForUser($user, $this->month, $this->year); }) @@ -652,7 +661,7 @@ class TeamController extends Controller $currentLevel = $user->user_level; // Lade alle aktiven User Level, sortiert nach Position - $userLevels = \App\Models\UserLevel::where('active', true) + $userLevels = UserLevel::where('active', true) ->orderBy('pos', 'asc') ->get(); @@ -706,7 +715,7 @@ class TeamController extends Controller * Konsolidierte Team-Abo-Übersicht: Abos im Team nach Ebenen/Linien gruppiert, * inkl. Eigen-Abos, Kundenabos und neuer Abos im gewählten Monat. */ - public function showAbos(\App\Services\Abo\TeamAboOverviewService $overviewService) + public function showAbos(TeamAboOverviewService $overviewService, PaylineForecastService $paylineService) { $user = User::find(\Auth::user()->id); $now = now(); @@ -725,6 +734,7 @@ class TeamController extends Controller 'growthChart' => $this->buildGrowthChart($growth), 'growthYear' => $growthYear, 'filterYears' => HTMLHelper::getYearRange(2022), + 'paylineAboTurnover' => $paylineService->aboTurnover($user, (int) $now->month, (int) $now->year), ]; return view('user.team.abos', $data); @@ -767,7 +777,7 @@ class TeamController extends Controller * (`user_abos_team_show`) eingeflossen. Diese Route bleibt für Bookmarks/Altlinks * bestehen und leitet dauerhaft dorthin weiter. */ - public function showTeamCustomerAbos(): \Illuminate\Http\RedirectResponse + public function showTeamCustomerAbos(): RedirectResponse { return redirect()->route('user_abos_team_show'); } @@ -778,7 +788,7 @@ class TeamController extends Controller public function detailAbo($id) { $user = User::find(\Auth::user()->id); - $user_abo = \App\Models\UserAbo::findOrFail($id); + $user_abo = UserAbo::findOrFail($id); // Prüfe ob das Abo zu einem Team-Mitglied gehört (effizient via Sponsor-Kette) if (! AboHelper::isUserInTeam($user->id, $user_abo->user_id)) { @@ -786,9 +796,9 @@ class TeamController extends Controller } // Lade Abo-Details (ähnlich wie AboController) - \App\Services\AboOrderCart::initYard($user_abo); - $customer_detail = \App\Services\AboOrderCart::getCustomerDetail(); - \App\Services\AboOrderCart::makeOrderYard($user_abo); + AboOrderCart::initYard($user_abo); + $customer_detail = AboOrderCart::getCustomerDetail(); + AboOrderCart::makeOrderYard($user_abo); $data = [ 'user_abo' => $user_abo, @@ -1071,7 +1081,7 @@ class TeamController extends Controller { $levels = [0 => 'Alle Level']; - $userLevels = \App\Models\UserLevel::orderBy('pos')->get(['id', 'name']); + $userLevels = UserLevel::orderBy('pos')->get(['id', 'name']); foreach ($userLevels as $level) { $levels[$level->id] = $level->name; } diff --git a/app/Http/Controllers/UserShopController.php b/app/Http/Controllers/UserShopController.php index 7c466847..381c0aef 100644 --- a/app/Http/Controllers/UserShopController.php +++ b/app/Http/Controllers/UserShopController.php @@ -1,22 +1,21 @@ shop && !$user->shop->set_defaults) { + if ($user->shop && ! $user->shop->set_defaults) { if ($user->account) { - $user->shop->title = $user->account->first_name . " " . $user->account->last_name; + $user->shop->title = $user->account->first_name.' '.$user->account->last_name; } if ($user->account) { $user->shop->contact = $this->generate_contact($user); @@ -45,6 +44,7 @@ class UserShopController extends Controller $data = [ 'user' => $user, ]; + return view('user.shop', $data); } @@ -56,6 +56,7 @@ class UserShopController extends Controller 'localizations' => array_keys(config('localization.supportedLocales')), ]; + return view('user.shop.translate', $data); } @@ -64,13 +65,14 @@ class UserShopController extends Controller $user = Auth::user(); $data = Request::all(); - if (!$user->shop) { + if (! $user->shop) { abort(404); } foreach ($data['trans'] as $lang => $val) { $this->storeTranslations($user->shop, $lang, $val); } \Session()->flash('alert-save', true); + return redirect(route('user_shop_translate')); } @@ -79,7 +81,7 @@ class UserShopController extends Controller $user = Auth::user(); $data = Request::all(); - if (!$user->shop) { + if (! $user->shop) { abort(404); } $user->shop->title = $data['title']; @@ -100,34 +102,35 @@ class UserShopController extends Controller $user_shop->accessibility = trim(preg_replace('/\s*\n+/', "\n", $data['accessibility'])); $user_shop->about = trim(preg_replace('/\s+/', ' ', $data['about'])); $user_shop->save(); + return; } $trans = $user_shop->trans; $trans[$lang]['contact'] = trim(preg_replace('/\s*\n+/', "\n", $data['contact'])); $trans[$lang]['accessibility'] = trim(preg_replace('/\s*\n+/', "\n", $data['accessibility'])); - $trans[$lang]['about'] = trim(preg_replace('/\s+/', ' ', $data['about'])); + $trans[$lang]['about'] = trim(preg_replace('/\s+/', ' ', $data['about'])); $user_shop->trans = $trans; $user_shop->save(); - return; + } private function generate_contact($user) { - $ret = ""; + $ret = ''; $sep = "\n"; - $ret = $user->account->street != "" ? $user->account->street : __('shop.your_street_number'); - $ret .= " • "; - $ret .= $user->account->postal_code != "" ? $user->account->postal_code . " " : __('shop.your_zip_code'); - $ret .= $user->account->city != "" ? $user->account->city : __('shop.your_city'); + $ret = $user->account->street != '' ? $user->account->street : __('shop.your_street_number'); + $ret .= ' • '; + $ret .= $user->account->postal_code != '' ? $user->account->postal_code.' ' : __('shop.your_zip_code'); + $ret .= $user->account->city != '' ? $user->account->city : __('shop.your_city'); $ret .= $sep; - $pre = $user->account->pre_phone_id != "" ? $user->account->pre_phone->phone . " " : ""; - $ret .= __('shop.phone') . ": " . ($user->account->phone != "" ? $pre . $user->account->phone : __('shop.your_phone_number')); + $pre = $user->account->pre_phone_id != '' ? $user->account->pre_phone->phone.' ' : ''; + $ret .= __('shop.phone').': '.($user->account->phone != '' ? $pre.$user->account->phone : __('shop.your_phone_number')); $ret .= $sep; - $pre = $user->account->pre_mobil_id != "" ? $user->account->pre_mobil->phone . " " : ""; - $ret .= __('shop.mobil') . ": " . ($user->account->mobil != "" ? $pre . $user->account->mobil : __('shop.your_mobile_number')); + $pre = $user->account->pre_mobil_id != '' ? $user->account->pre_mobil->phone.' ' : ''; + $ret .= __('shop.mobil').': '.($user->account->mobil != '' ? $pre.$user->account->mobil : __('shop.your_mobile_number')); $ret .= $sep; $ret .= $user->email; @@ -142,31 +145,32 @@ class UserShopController extends Controller $user = Auth::user(); - if (!$user->shop) { + if (! $user->shop) { abort(404); } try { - $image = \App\Services\Slim::getImages('images')[0]; + $image = Slim::getImages('images')[0]; if (isset($image['output']['data'])) { // Base64 of the image $data = $image['output']['data']; - $file_ex = array('image/jpeg' => 'jpg', 'image/png' => 'png'); + $file_ex = ['image/jpeg' => 'jpg', 'image/png' => 'png']; - if (!isset($file_ex[$image['output']['type']])) { + if (! isset($file_ex[$image['output']['type']])) { \Session()->flash('alert-danger', 'File is not jpg or png!'); + return redirect(route('user_shop')); } $ext = $file_ex[$image['output']['type']]; // Original file name $name = $image['output']['name']; - $name = \App\Services\Slim::sanitizeFileName($name); - $name = uniqid() . '_' . $name; + $name = Slim::sanitizeFileName($name); + $name = uniqid().'_'.$name; $data = \Storage::disk('public')->put( - 'images/shop/' . $name, + 'images/shop/'.$name, $data ); @@ -177,15 +181,16 @@ class UserShopController extends Controller $user->shop->size = $image['input']['size']; $user->shop->save(); - - \Session()->flash('alert-success', __('msg.file_uploaded')); + return redirect(route('user_shop')); } \Session()->flash('alert-danger', __('msg.file_empty')); + return redirect(route('user_shop')); } catch (\Exception $e) { - \Session()->flash('alert-danger', "Error: " . $e); + \Session()->flash('alert-danger', 'Error: '.$e); + return redirect(route('user_shop')); } } @@ -195,12 +200,12 @@ class UserShopController extends Controller $user = Auth::user(); - if (!$user->shop) { + if (! $user->shop) { abort(404); } if ($user->shop->filename) { - $file = 'images/shop/' . $user->shop->filename; + $file = 'images/shop/'.$user->shop->filename; \Storage::disk('public')->delete($file); $user->shop->filename = null; @@ -211,9 +216,11 @@ class UserShopController extends Controller $user->shop->save(); \Session()->flash('alert-success', __('msg.file_deleted')); + return redirect(route('user_shop')); } \Session()->flash('alert-danger', __('msg.file_not_found')); + return redirect(route('user_shop')); } @@ -223,32 +230,33 @@ class UserShopController extends Controller $user = Auth::user(); $user_shop_id = Request::get('user_shop_id'); - if (!$user->shop || $user->shop->id != $user_shop_id) { + if (! $user->shop || $user->shop->id != $user_shop_id) { abort(404); } try { - $image = \App\Services\Slim::getImages('images')[0]; + $image = Slim::getImages('images')[0]; if (isset($image['output']['data'])) { // Base64 of the image $data = $image['output']['data']; - $file_ex = array('image/jpeg' => 'jpg', 'image/png' => 'png'); + $file_ex = ['image/jpeg' => 'jpg', 'image/png' => 'png']; - if (!isset($file_ex[$image['output']['type']])) { + if (! isset($file_ex[$image['output']['type']])) { \Session()->flash('alert-danger', 'File is not jpg or png!'); + return redirect(route('user_shop')); } $ext = $file_ex[$image['output']['type']]; // Original file name $name = $image['output']['name']; - $name = \App\Services\Slim::sanitizeFileName($name); - $name = uniqid() . '_' . $name; + $name = Slim::sanitizeFileName($name); + $name = uniqid().'_'.$name; $data = \Storage::disk('public')->put( - 'images/user_shop/' . $user->shop->id . '/' . $name, + 'images/user_shop/'.$user->shop->id.'/'.$name, $data ); @@ -258,16 +266,19 @@ class UserShopController extends Controller 'original_name' => $image['output']['name'], 'ext' => $ext, 'mine' => $image['output']['type'], - 'size' => $image['input']['size'] + 'size' => $image['input']['size'], ]); \Session()->flash('alert-success', __('msg.file_uploaded')); + return redirect(route('user_shop')); } \Session()->flash('alert-danger', __('msg.file_empty')); + return redirect(route('user_shop')); } catch (\Exception $e) { - \Session()->flash('alert-danger', "Error: " . $e); + \Session()->flash('alert-danger', 'Error: '.$e); + return redirect(route('user_shop')); } } @@ -276,74 +287,78 @@ class UserShopController extends Controller { $user = Auth::user(); - if (!$user->shop || $user->shop->id != $user_shop_id) { + if (! $user->shop || $user->shop->id != $user_shop_id) { abort(404); } $image = UserShopOnSite::findOrFail($image_id); if ($image->user_shop_id == $user_shop_id) { - $file = 'images/user_shop/' . $user_shop_id . '/' . $image->filename; + $file = 'images/user_shop/'.$user_shop_id.'/'.$image->filename; \Storage::disk('public')->delete($file); $image->delete(); \Session()->flash('alert-success', __('msg.file_deleted')); + return redirect(route('user_shop')); } \Session()->flash('alert-danger', __('msg.file_not_found')); + return redirect(route('user_shop')); } - - - public function userShopRegisterForm() { if (Request::get('shop_submit') == 'check') { - $rules = array( + $rules = [ 'user_shop_name' => ' required|alpha_dash|unique:user_shops,name|min:4|max:20|full_word_check', - ); + ]; Validator::extend('full_word_check', function ($attribute, $value, $parameters, $validator) { if (in_array($value, config('profanity.full_word_check'))) { return false; } + return true; }); $validator = Validator::make(Request::all(), $rules); if ($validator->fails()) { \Session()->flash('shop-name-error', 'error'); + return redirect()->back()->withErrors($validator)->withInput(Request::all()); } \Session()->flash('shop-name-error', 'check'); if (Request::get('user_shop_id')) { return back()->withInput(Request::all()); } + return redirect(route('user_shop'))->withInput(Request::all()); } if (Request::get('shop_submit') == 'action') { - $rules = array( + $rules = [ 'user_shop_name' => ' required|alpha_dash|unique:user_shops,name|min:4|max:20|full_word_check', 'user_shop_active' => 'accepted', - ); + ]; Validator::extend('full_word_check', function ($attribute, $value, $parameters, $validator) { if (in_array($value, config('profanity.full_word_check'))) { return false; } + return true; }); $validator = Validator::make(Request::all(), $rules); if ($validator->fails()) { \Session()->flash('shop-name-error', 'error'); + return redirect()->back()->withErrors($validator)->withInput(Request::all()); } \Session()->flash('shop-name-error', 'check'); - //all is right - save + // all is right - save $user = Auth::user(); $data = Request::all(); $slug = SlugService::createSlug(UserShop::class, 'slug', $data['user_shop_name']); @@ -367,93 +382,51 @@ class UserShopController extends Controller ); } \Session()->flash('alert-save', true); + return redirect(route('user_shop')); - /*$ret = $this->userShopRegisterSubDomain($user_shop->slug); - if($ret['success'] === true){ - \Session()->flash('alert-save', true); - }else{ - $user_shop->forceDelete(); - \Session()->flash('alert-error', $ret['error']); - } - return redirect(route('user_shop'));*/ } } - - public function userShopRegisterSubDomain($slug) - { - - $kas = new KasController(); - $domain = 'mivita.care'; - - - //check if exisist - $subdomains = $kas->action('get_subdomains'); - foreach ($subdomains as $subdomain) { - if (!isset($subdomain['subdomain_name'])) { - continue; - } - $sub = str_replace("." . $domain, '', $subdomain['subdomain_name']); - if ($sub == $slug) { - return ['success' => false, 'error' => __('shop.error_subdomain_exists')]; - } - } - //add - $full_subdomain_name = $slug . "." . $domain; - $pra = array( - 'subdomain_name' => $slug, - 'domain_name' => $domain, - 'subdomain_path' => '/mein.mivita.care/public/', - 'php_version' => config('app.php_version'), - //'ssl_proxy' => 'Y', - //'redirect_status' => 0 - ); - $add_subdomain = $kas->action('add_subdomain', $pra); - if ($add_subdomain == $full_subdomain_name) { - return ['success' => true]; - } - return ['success' => false, 'error' => $add_subdomain]; - } - /** * @return string to ajax */ public function checkUserShopName() { - $rules = array( + $rules = [ 'user_shop_name' => ' required|alpha_dash|unique:user_shops,name|min:4|max:20|full_word_check', - ); + ]; Validator::extend('full_word_check', function ($attribute, $value, $parameters, $validator) { if (in_array($value, config('profanity.full_word_check'))) { return false; } + return true; }); $validator = Validator::make(Request::all(), $rules); if ($validator->fails()) { - //$messages = $validator->messages(); - return Response::json(array( + // $messages = $validator->messages(); + return Response::json([ 'success' => false, - 'errors' => $validator->getMessageBag()->toArray() + 'errors' => $validator->getMessageBag()->toArray(), - )); + ]); } $slug = SlugService::createSlug(UserShop::class, 'slug', Request::get('user_shop_name')); - return Response::json(array( + return Response::json([ 'success' => true, - 'preview_user_shop_name' => "https://" . $slug . "." . config('app.domain') . config('app.tld_care'), - )); + 'preview_user_shop_name' => 'https://'.$slug.'.'.config('app.domain').config('app.tld_care'), + ]); } public function editName() { $user = Auth::user(); $user_shop = $user->shop; - if (!$user_shop) { + if (! $user_shop) { abort(404); } $user_shop_domain = $user_shop->getSubdomain(false); @@ -462,6 +435,7 @@ class UserShopController extends Controller 'user_shop_id' => $user_shop->id, 'user_shop_domain' => $user_shop_domain, ]; + return view('user.shop_edit_name', $data); } } diff --git a/app/Http/Controllers/Web/SiteController.php b/app/Http/Controllers/Web/SiteController.php index 72a09a3e..5513efec 100644 --- a/app/Http/Controllers/Web/SiteController.php +++ b/app/Http/Controllers/Web/SiteController.php @@ -9,11 +9,43 @@ use App\Models\Product; use App\Models\ProductCategory; use App\Services\LocaleGuard; use App\Services\Shop; +use App\Services\UserService; use App\Services\Util; use Request; class SiteController extends Controller { + /** + * Templates, die eigene Controller mit eigenen View-Daten haben und daher + * nicht über die Catch-all-Route ({site}/{subsite?}) gerendert werden dürfen. + * + * @var array + */ + private const PROTECTED_TEMPLATES = [ + 'checkout', + 'checkout-error', + 'checkout-final', + 'checkout-is-final', + 'card', + 'kontakt', + 'contact-final', + 'registrierung', + 'registrierung_finish', + 'widerruf', + 'widerruf-final', + 'produkte-show', + 'produkte-item', + 'versandkosten', + 'zahlungsarten', + ]; + + private function isRenderableTemplate(string $template): bool + { + return ! str_starts_with($template, '_') + && ! in_array($template, self::PROTECTED_TEMPLATES, true) + && view()->exists('web.templates.'.$template); + } + public function index() { $this->setIPInfo(); @@ -83,7 +115,7 @@ class SiteController extends Controller // ist default // sprache - if (array_key_exists($country, \App\Services\UserService::getTransChange())) { + if (array_key_exists($country, UserService::getTransChange())) { \Session::put('user_init_country', $country); \Session::put('locale', $country); \App::setLocale($country); @@ -177,13 +209,13 @@ class SiteController extends Controller 'yard_instance' => 'webshop', ]; if ($subsite) { - if (! view()->exists('web.templates.'.$subsite)) { + if (! $this->isRenderableTemplate($subsite)) { abort(404); } return view('web.templates.'.$subsite, $data); } - if (! view()->exists('web.templates.'.$site)) { + if (! $this->isRenderableTemplate($site)) { abort(404); } diff --git a/app/Models/ShoppingOrder.php b/app/Models/ShoppingOrder.php index bc8a9093..91a8d592 100644 --- a/app/Models/ShoppingOrder.php +++ b/app/Models/ShoppingOrder.php @@ -2,9 +2,14 @@ namespace App\Models; +use Acme\Dhl\Models\DhlShipment; +use App\Services\HTMLHelper; +use App\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Carbon; /** * App\Models\ShoppingOrder @@ -23,16 +28,16 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property int|null $weight * @property int|null $paid * @property string|null $txaction - * @property \Illuminate\Support\Carbon|null $created_at - * @property \Illuminate\Support\Carbon|null $updated_at - * @property-read \App\User|null $auth_user - * @property-read \App\Models\Country $country - * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrderItem[] $shopping_order_items + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read User|null $auth_user + * @property-read Country $country + * @property-read Collection|ShoppingOrderItem[] $shopping_order_items * @property-read int|null $shopping_order_items_count - * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingPayment[] $shopping_payments + * @property-read Collection|ShoppingPayment[] $shopping_payments * @property-read int|null $shopping_payments_count - * @property-read \App\Models\ShoppingUser $shopping_user - * @property-read \App\Models\UserShop $user_shop + * @property-read ShoppingUser $shopping_user + * @property-read UserShop $user_shop * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrder newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrder newQuery() @@ -60,13 +65,13 @@ use Illuminate\Database\Eloquent\SoftDeletes; * * @property int|null $member_id * @property string|null $mode - * @property-read \App\User|null $member - * @property-read \App\Models\UserHistory|null $user_history + * @property-read User|null $member + * @property-read UserHistory|null $user_history * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrder whereMemberId($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingOrder whereMode($value) * - * @property \Illuminate\Support\Carbon|null $deleted_at + * @property Carbon|null $deleted_at * @property string|null $user_deleted_at * * @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrder onlyTrashed() @@ -75,7 +80,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrder withTrashed() * @method static \Illuminate\Database\Query\Builder|\App\Models\ShoppingOrder withoutTrashed() * - * @property-read \App\Models\ShippingCountry $shipping_country + * @property-read ShippingCountry $shipping_country * @property float|null $shipping_net * @property float|null $subtotal_ws * @property int|null $points @@ -93,21 +98,21 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property int|null $homeparty_id * @property array|null $api_notice * @property int|null $api_status - * @property-read \App\Models\Homeparty|null $homeparty + * @property-read Homeparty|null $homeparty * * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereHomepartyId($value) * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereWpNotice($value) * * @property array|null $tax_split - * @property-read \App\Models\UserInvoice|null $user_invoice - * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserSalesVolume[] $user_sales_volume + * @property-read UserInvoice|null $user_invoice + * @property-read Collection|UserSalesVolume[] $user_sales_volume * @property-read int|null $user_sales_volume_count * * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereTaxSplit($value) * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereApiNotice($value) * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereApiStatus($value) * - * @property-read \App\Models\ShoppingCollectOrder|null $shopping_collect_order + * @property-read ShoppingCollectOrder|null $shopping_collect_order * @property array|null $net_split * * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereNetSplit($value) @@ -122,11 +127,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereAboInterval($value) * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrder whereIsAbo($value) * - * @property-read \Illuminate\Database\Eloquent\Collection $dhlOutboundShipments + * @property-read Collection $dhlOutboundShipments * @property-read int|null $dhl_outbound_shipments_count - * @property-read \Illuminate\Database\Eloquent\Collection $dhlReturnShipments + * @property-read Collection $dhlReturnShipments * @property-read int|null $dhl_return_shipments_count - * @property-read \Illuminate\Database\Eloquent\Collection $dhlShipments + * @property-read Collection $dhlShipments * @property-read int|null $dhl_shipments_count * * @mixin \Eloquent @@ -608,7 +613,7 @@ class ShoppingOrder extends Model */ public function isCancellationInvoice() { - return \App\Models\UserInvoice::where('shopping_order_id', $this->id) + return UserInvoice::where('shopping_order_id', $this->id) ->where('cancellation', true) ->whereNull('cancellation_id') ->exists(); @@ -620,7 +625,7 @@ class ShoppingOrder extends Model */ public function getCancellationInvoice() { - return \App\Models\UserInvoice::where('shopping_order_id', $this->id) + return UserInvoice::where('shopping_order_id', $this->id) ->where('cancellation', true) ->whereNull('cancellation_id') ->first(); @@ -710,13 +715,13 @@ class ShoppingOrder extends Model } foreach ($tax_split as $key => $value) { - $tax_split[$key]['vk_tax'] = number_format($value['vk_tax'], 2); - $tax_split[$key]['ek_tax'] = number_format($value['ek_tax'], 2); + $tax_split[$key]['vk_tax'] = number_format($value['vk_tax'], 2, '.', ''); + $tax_split[$key]['ek_tax'] = number_format($value['ek_tax'], 2, '.', ''); } foreach ($net_split as $key => $value) { - $net_split[$key]['vk_net'] = number_format($value['vk_net'], 2); - $net_split[$key]['ek_net'] = number_format($value['ek_net'], 2); + $net_split[$key]['vk_net'] = number_format($value['vk_net'], 2, '.', ''); + $net_split[$key]['ek_net'] = number_format($value['ek_net'], 2, '.', ''); } } if (! isset($tax_split[16]) && ! isset($tax_split[19])) { @@ -779,10 +784,10 @@ class ShoppingOrder extends Model } foreach ($tax_split as $key => $value) { - $tax_split[$key] = number_format($value, 2); + $tax_split[$key] = number_format($value, 2, '.', ''); } foreach ($net_split as $key => $value) { - $net_split[$key] = number_format($value, 2); + $net_split[$key] = number_format($value, 2, '.', ''); } } $this->tax_split = $tax_split; @@ -798,7 +803,7 @@ class ShoppingOrder extends Model $fullname = $this->shopping_user->getFullNameAsArray(); $ret = ''; $ret .= $fullname['company'] ? $fullname['company'].' | ' : ''; - $ret .= $fullname['salutation'] ? \App\Services\HTMLHelper::getSalutationLang($fullname['salutation']).' ' : ''; + $ret .= $fullname['salutation'] ? HTMLHelper::getSalutationLang($fullname['salutation']).' ' : ''; $ret .= $fullname['firstname'] ? $fullname['firstname'].' ' : ''; $ret .= $fullname['lastname'] ? $fullname['lastname'] : ''; $ret .= $fullname['email'] ? ' | '.$fullname['email'].'' : ''; diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index 35de5402..e3ea3d91 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -91,6 +91,23 @@ class UserAbo extends Model protected $table = 'user_abos'; + /** + * Ein gelöschtes Abo darf nicht aktiv bleiben: Softdelete setzt nur + * deleted_at, wodurch active=1-Leichen entstehen, die Incentive-, + * Statistik- und Ist/Soll-Zahlen auseinanderlaufen lassen. Deshalb wird + * active beim Löschen immer mitgesetzt (gilt für Model-Deletes; Query- + * Builder-Deletes lösen keine Model-Events aus). + */ + protected static function booted(): void + { + static::deleting(function (UserAbo $abo): void { + if ($abo->active) { + $abo->active = false; + $abo->saveQuietly(); + } + }); + } + protected $casts = [ 'user_id' => 'int', 'member_id' => 'int', @@ -105,6 +122,8 @@ class UserAbo extends Model 'next_date' => 'datetime', 'onetime_reminder_sent_for' => 'date', 'cancel_date' => 'datetime', + 'last_pause_date' => 'date', + 'pause_count' => 'int', 'user_deleted_at' => 'datetime', 'carddata' => 'array', 'card_update_pending_at' => 'datetime', @@ -133,6 +152,8 @@ class UserAbo extends Model 'next_date', 'onetime_reminder_sent_for', 'cancel_date', + 'last_pause_date', + 'pause_count', 'user_deleted_at', 'card_update_pending_at', ]; @@ -355,6 +376,11 @@ class UserAbo extends Model return $this->hasMany(UserAboItem::class); } + public function status_histories() + { + return $this->hasMany(UserAboStatusHistory::class); + } + public function one_time_items() { return $this->hasMany(UserAboOneTimeItem::class); diff --git a/app/Models/UserAboStatusHistory.php b/app/Models/UserAboStatusHistory.php new file mode 100644 index 00000000..1f6a2a83 --- /dev/null +++ b/app/Models/UserAboStatusHistory.php @@ -0,0 +1,127 @@ + 'abo_history.status_paused', + self::ACTION_CANCELLED => 'abo_history.status_cancelled', + self::ACTION_DEACTIVATED => 'abo_history.status_deactivated', + self::ACTION_REACTIVATED => 'abo_history.status_reactivated', + ]; + + public static $actionColors = [ + self::ACTION_PAUSED => 'warning', + self::ACTION_CANCELLED => 'danger', + self::ACTION_DEACTIVATED => 'secondary', + self::ACTION_REACTIVATED => 'success', + ]; + + /** + * Formular-View → Kanal-Label/Farbe der Item-Historie (gleiche Optik im Protokoll). + */ + public static $viewChannelMap = [ + 'me' => 'user_me', + 'ot' => 'user_ot', + 'portal' => 'portal', + 'admin' => 'admin', + ]; + + protected $fillable = [ + 'user_abo_id', + 'action', + 'view', + 'actor_user_id', + 'previous_next_date', + 'new_next_date', + ]; + + protected $casts = [ + 'user_abo_id' => 'int', + 'actor_user_id' => 'int', + 'previous_next_date' => 'date', + 'new_next_date' => 'date', + ]; + + public function user_abo(): BelongsTo + { + return $this->belongsTo(UserAbo::class); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_user_id'); + } + + public function getActionBadge(): string + { + $label = isset(self::$actionLabels[$this->action]) ? __(self::$actionLabels[$this->action]) : $this->action; + $color = self::$actionColors[$this->action] ?? 'secondary'; + + return ''.$label.''; + } + + public function getChannelBadge(): string + { + $channel = self::$viewChannelMap[$this->view] ?? 'system'; + $label = isset(UserAboItemHistory::$channelLabels[$channel]) ? __(UserAboItemHistory::$channelLabels[$channel]) : $channel; + $color = UserAboItemHistory::$channelColors[$channel] ?? 'secondary'; + + return ''.$label.''; + } + + public function getDescription(): string + { + if ($this->action === self::ACTION_PAUSED && $this->previous_next_date && $this->new_next_date) { + return __('abo_history.status_shift', [ + 'from' => $this->previous_next_date->format(\Util::formatDateDB()), + 'to' => $this->new_next_date->format(\Util::formatDateDB()), + ]); + } + + return ''; + } + + public function getFormattedDate(): string + { + return $this->created_at ? $this->created_at->format(\Util::formatDateDB().' H:i') : ''; + } + + public function getActorName(): string + { + if (! $this->actor) { + return '-'; + } + + $name = trim(($this->actor->account?->first_name ?? '').' '.($this->actor->account?->last_name ?? '')); + + return $name !== '' ? $name : ($this->actor->email ?? '-'); + } +} diff --git a/app/Models/UserShop.php b/app/Models/UserShop.php index e1169d6e..90bcc427 100644 --- a/app/Models/UserShop.php +++ b/app/Models/UserShop.php @@ -2,10 +2,10 @@ namespace App\Models; -use App\Http\Controllers\Api\KasController; -use App\Http\Controllers\Api\KasSLLController; +use App\User; use Carbon\Carbon; use Cviebrock\EloquentSluggable\Sluggable; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -32,9 +32,10 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $deleted_at - * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserShopOnSite[] $on_sites + * @property-read Collection|UserShopOnSite[] $on_sites * @property-read int|null $on_sites_count - * @property-read \App\User $user + * @property-read User $user + * * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\UserShop findSimilarSlugs($attribute, $config, $slug) * @method static bool|null forceDelete() * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\UserShop newModelQuery() @@ -65,16 +66,16 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static \Illuminate\Database\Query\Builder|\App\Models\UserShop withTrashed() * @method static \Illuminate\Database\Query\Builder|\App\Models\UserShop withoutTrashed() * @method static \Illuminate\Database\Eloquent\Builder|UserShop withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug) + * * @property array|null $trans + * * @method static \Illuminate\Database\Eloquent\Builder|UserShop whereTrans($value) + * * @mixin \Eloquent */ class UserShop extends Model { protected $table = 'user_shops'; - private $is_online = NULL; - private $ssl_certificate_sni = NULL; - private $ssl_certificate_sni_is_active = NULL; protected $casts = [ 'featured' => 'array', @@ -86,15 +87,17 @@ class UserShop extends Model ]; use SoftDeletes; + protected $dates = ['deleted_at']; + use Sluggable; - - public function sluggable() : array + + public function sluggable(): array { return [ 'slug' => [ - 'source' => 'name' - ] + 'source' => 'name', + ], ]; } @@ -103,18 +106,27 @@ class UserShop extends Model return $this->belongsTo('App\User', 'user_id'); } - public function on_sites(){ + public function on_sites() + { return $this->hasMany('App\Models\UserShopOnSite', 'user_shop_id', 'id'); } - public function getActiveDateFormat(){ - if(!$this->attributes['active_date']){ return ""; } + public function getActiveDateFormat() + { + if (! $this->attributes['active_date']) { + return ''; + } + return Carbon::parse($this->attributes['active_date'])->format(\Util::formatDateTimeDB()); } - public function getActiveDateFormatSmall(){ - if(!$this->attributes['active_date']){ return ""; } - return Carbon::parse($this->attributes['active_date'])->format("d.m.Y"); + public function getActiveDateFormatSmall() + { + if (! $this->attributes['active_date']) { + return ''; + } + + return Carbon::parse($this->attributes['active_date'])->format('d.m.Y'); } public function getLang($key, $default = true) @@ -123,6 +135,7 @@ class UserShop extends Model if ($lang == 'de') { return $this->{$key}; } + return $this->getTrans($key, $lang, $default); } @@ -131,126 +144,70 @@ class UserShop extends Model if ($lang == 'de') { return $this->{$key}; } - if(!empty($this->trans[$lang][$key])){ + if (! empty($this->trans[$lang][$key])) { return $this->trans[$lang][$key]; } - if($default){ - return !empty($this->{$key}) ? $this->{$key} : ''; + if ($default) { + return ! empty($this->{$key}) ? $this->{$key} : ''; } - return ""; + + return ''; } - public function getSubdomain($session=true) + public function getSubdomain($session = true) { - if($session && \Session::has('user_shop_domain')){ + if ($session && \Session::has('user_shop_domain')) { return \Session::get('user_shop_domain'); } - return config('app.protocol').$this->attributes['slug'].".".config('app.domain').config('app.tld_care'); + + return config('app.protocol').$this->attributes['slug'].'.'.config('app.domain').config('app.tld_care'); } - public function getSubdomainStatus() + public function getSubdomainAvailable() { - if($this->is_online !== NULL){ - return $this->is_online; - } + $rCurlHandle = curl_init($this->attributes['slug'].'.mivita.care'); - $kas = new KasController(); - $domain = 'mivita.care'; - $pra = array( - 'subdomain_name' => $this->attributes['slug'].".".$domain, - 'domain_name' => $domain, - ); + curl_setopt($rCurlHandle, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($rCurlHandle, CURLOPT_HEADER, true); + curl_setopt($rCurlHandle, CURLOPT_NOBODY, true); + curl_setopt($rCurlHandle, CURLOPT_RETURNTRANSFER, true); - //check if exisist - $subdomain = $kas->action('get_subdomains', $pra); - if(is_soap_fault($subdomain)){ + $strResponse = curl_exec($rCurlHandle); + + curl_close($rCurlHandle); + + if (! $strResponse) { return false; } - - if(!empty($subdomain[0]['is_active']) && $subdomain[0]['is_active'] == 'Y'){ - $this->is_online = true; - }else{ - $this->is_online = false; - } - - if(!empty($subdomain[0]['ssl_certificate_sni']) && $subdomain[0]['ssl_certificate_sni'] == 'Y'){ - $this->ssl_certificate_sni = true; - }else{ - $this->ssl_certificate_sni = false; - $this->userShopAddSSL($subdomain[0]['subdomain_name']); - } - - if(!empty($subdomain[0]['ssl_certificate_sni_is_active']) && $subdomain[0]['ssl_certificate_sni_is_active'] == 'j'){ - $this->ssl_certificate_sni_is_active = true; - }else{ - $this->ssl_certificate_sni_is_active = false; - } - - return $this->is_online; - } - - public function userShopAddSSL($full_subdomain_name) - { - $kas = new KasController(); - $ssl = KasSLLController::getApiSSLParameter(); - $pra = array( - 'hostname' => $full_subdomain_name - ); - $pra = array_merge($pra, $ssl); - $value = $kas->action('update_ssl', $pra); - return $value; - } - public function getSubdomainSslSin () - { - return $this->ssl_certificate_sni; - } - public function getSubdomainSslSinActive () - { - return $this->ssl_certificate_sni_is_active; - } - public function getSubdomainAvailable () - { - $rCurlHandle = curl_init ( $this->attributes['slug'].".mivita.care" ); - - curl_setopt ( $rCurlHandle, CURLOPT_CONNECTTIMEOUT, 10 ); - curl_setopt ( $rCurlHandle, CURLOPT_HEADER, TRUE ); - curl_setopt ( $rCurlHandle, CURLOPT_NOBODY, TRUE ); - curl_setopt ( $rCurlHandle, CURLOPT_RETURNTRANSFER, TRUE ); - - $strResponse = curl_exec ( $rCurlHandle ); - - curl_close ( $rCurlHandle ); - - if ( !$strResponse ) - { - return FALSE; - } - - $url = "http://".$this->attributes['slug'].".mivita.care/domain/check"; - if(@file_get_contents($url) != 'checked'){ - return FALSE; - } - return TRUE; - } - - - - public function isImage(){ - if(empty($this->attributes['filename']) || @$this->attributes['filename'] == null || @$this->attributes['filename'] == ""){ - return false; - } - if(!\Storage::disk('public')->has('images/shop/'.$this->filename)){ + $url = 'http://'.$this->attributes['slug'].'.mivita.care/domain/check'; + if (@file_get_contents($url) != 'checked') { return false; } + return true; } - public function getImage(){ - if($this->isImage()){ + public function isImage() + { + if (empty($this->attributes['filename']) || @$this->attributes['filename'] == null || @$this->attributes['filename'] == '') { + return false; + } + if (! \Storage::disk('public')->has('images/shop/'.$this->filename)) { + return false; + } + + return true; + } + + public function getImage() + { + if ($this->isImage()) { $link = 'images/shop/'.$this->filename; + return '/storage/'.$link.'?=lm='.\Storage::disk('public')->lastModified($link); } + return false; } -} \ No newline at end of file +} diff --git a/app/Repositories/AboRepository.php b/app/Repositories/AboRepository.php index 54f116c0..043ff9bc 100644 --- a/app/Repositories/AboRepository.php +++ b/app/Repositories/AboRepository.php @@ -2,7 +2,9 @@ namespace App\Repositories; +use App\Models\Setting; use App\Models\UserAbo; +use App\Models\UserAboStatusHistory; use App\Services\AboHelper; use Carbon\Carbon; @@ -19,7 +21,7 @@ class AboRepository extends BaseRepository */ public static function getLockDaysChange(): int { - $days = (int) \App\Models\Setting::getContentBySlug('abo-lock-days-change'); + $days = (int) Setting::getContentBySlug('abo-lock-days-change'); return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_CHANGE; } @@ -31,7 +33,7 @@ class AboRepository extends BaseRepository */ public static function getLockDaysPauseCancel(): int { - $days = (int) \App\Models\Setting::getContentBySlug('abo-lock-days-pause-cancel'); + $days = (int) Setting::getContentBySlug('abo-lock-days-pause-cancel'); return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_PAUSE_CANCEL; } @@ -51,6 +53,13 @@ class AboRepository extends BaseRepository if (isset($data['action'])) { if ($data['action'] === 'abo_update_settings') { if ($this->validate($data)) { + // Pause ist eine eigenständige Aktion: sie verschiebt nur next_date + // und darf nicht von der anschließenden Liefertag-Neuberechnung + // überschrieben werden. + if (isset($data['abo_pause']) && $data['abo_pause'] == 'true') { + return $this->pauseOneMonth($data); + } + $this->updateStatus($data); $this->model->abo_interval = (int) $data['abo_interval']; $nextDate = $this->isAdminUpdate($data) @@ -60,7 +69,7 @@ class AboRepository extends BaseRepository $this->model->save(); $daysUntilNext = AboHelper::calendarDaysUntil(now(), $nextDate); - \Session()->flash('alert-warning', __('abo.warning_next_date_info', ['days' => $daysUntilNext, 'date' => $nextDate->format('d.m.Y')])); + \Session()->flash('alert-warning', __('abo.warning_next_date_info', ['days' => $daysUntilNext, 'date' => $nextDate->format(\Util::formatDateDB())])); return true; } @@ -80,9 +89,18 @@ class AboRepository extends BaseRepository // Handle cancellation if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') { + // Mindestlaufzeit serverseitig durchsetzen — die UI blendet den Button nur aus + if (! $isAdminUpdate && ! AboHelper::canCancelAbo($this->model, $data['view'] ?? 'user')) { + \Session()->flash('alert-error', __('abo.info_min_duration_orders_left', [ + 'count' => max(0, (int) AboHelper::getAboMinDuration() - $this->model->getCountPaidOrders()), + ])); + + return; + } + // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden if (! $isAdminUpdate && $this->model->next_date) { - $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); + $daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false); if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) { \Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()])); @@ -96,13 +114,15 @@ class AboRepository extends BaseRepository $this->model->cancel_date = now(); $this->model->save(); + $this->logStatusHistory(UserAboStatusHistory::ACTION_CANCELLED, $data); + return; } $active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false; // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) { - $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); + $daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false); if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) { \Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()])); @@ -115,6 +135,8 @@ class AboRepository extends BaseRepository $this->model->status = 6; // inactive $this->model->active = false; $this->model->save(); + + $this->logStatusHistory(UserAboStatusHistory::ACTION_DEACTIVATED, $data); } } if (! $this->model->active && $active) { @@ -122,11 +144,88 @@ class AboRepository extends BaseRepository $this->model->status = 2; // okay $this->model->active = true; $this->model->save(); + + $this->logStatusHistory(UserAboStatusHistory::ACTION_REACTIVATED, $data); } } $this->model->active = $active; } + /** + * Pausiert das Abo um genau einen Ausführungs-Zyklus: next_date wandert auf den + * gleichen Liefertag des Folgemonats, das Abo bleibt aktiv (Status 2). + * Regeln (außer Admin): Sperrfrist vor der Ausführung, Mindestlaufzeit und + * max. 1 Pause pro X Monate ({@see AboHelper::canPauseAbo()}). + */ + private function pauseOneMonth(array $data): bool + { + // Zeilen-Lock + frisches Modell: zwei parallele Pause-Requests dürfen next_date + // nicht beide auf Basis des alten Standes verschieben (Doppel-Pause). + return \DB::transaction(function () use ($data): bool { + $this->model = UserAbo::whereKey($this->model->id)->lockForUpdate()->firstOrFail(); + + return $this->pauseOneMonthLocked($data); + }); + } + + private function pauseOneMonthLocked(array $data): bool + { + $isAdminUpdate = $this->isAdminUpdate($data); + + if (! $this->model->active || (int) $this->model->status !== 2 || ! $this->model->getRawOriginal('next_date')) { + \Session()->flash('alert-error', __('abo.error_pause_not_active')); + + return false; + } + + if (! $isAdminUpdate) { + $daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->getRawOriginal('next_date')), false); + if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) { + \Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()])); + + return false; + } + + if (! AboHelper::canCancelAbo($this->model, $data['view'] ?? 'user')) { + \Session()->flash('alert-error', __('abo.info_min_duration_orders_left', ['count' => (int) AboHelper::getAboMinDuration() - $this->model->getCountPaidOrders()])); + + return false; + } + + if (($nextPossible = AboHelper::nextPossiblePauseDate($this->model)) !== null) { + \Session()->flash('alert-error', __('abo.error_pause_interval', ['months' => AboHelper::getPauseIntervalMonths(), 'date' => $nextPossible->format(\Util::formatDateDB())])); + + return false; + } + } + + $previousNextDate = Carbon::parse($this->model->getRawOriginal('next_date')); + $newNextDate = AboHelper::setNextDate($previousNextDate, (int) $this->model->abo_interval); + + $this->model->next_date = $newNextDate; + $this->model->last_pause_date = now(); + $this->model->pause_count = (int) $this->model->pause_count + 1; + $this->model->save(); + + $this->logStatusHistory(UserAboStatusHistory::ACTION_PAUSED, $data, $previousNextDate, $newNextDate); + + \Session()->flash('alert-success', __('abo.pause_success', ['date' => $newNextDate->format(\Util::formatDateDB())])); + + return true; + } + + private function logStatusHistory(string $action, array $data, ?Carbon $previousNextDate = null, ?Carbon $newNextDate = null): void + { + UserAboStatusHistory::create([ + 'user_abo_id' => $this->model->id, + 'action' => $action, + 'view' => $data['view'] ?? null, + 'actor_user_id' => \Auth::id(), + 'previous_next_date' => $previousNextDate, + 'new_next_date' => $newNextDate, + ]); + } + private function validate($data) { if ($data['view'] !== 'admin' && $data['view'] !== 'portal') { @@ -151,7 +250,7 @@ class AboRepository extends BaseRepository return false; } } - if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays, true)) { + if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], UserAbo::$aboDeliveryDays, true)) { \Session()->flash('alert-error', __('abo.error_abo_interval')); return false; @@ -181,7 +280,7 @@ class AboRepository extends BaseRepository if ($intervalChanged) { // Sperre: X Tage vor nächster Ausführung kein Liefertag-Wechsel mehr (Pakete werden vorgepackt) if ($this->model->next_date) { - $daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); + $daysUntilExecution = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false); if ($daysUntilExecution >= 0 && $daysUntilExecution < self::getLockDaysChange()) { \Session()->flash('alert-error', __('abo.error_change_locked', ['days' => $daysUntilExecution, 'lock' => self::getLockDaysChange()])); @@ -208,12 +307,12 @@ class AboRepository extends BaseRepository * Falls next_date bereits in einem zukünftigen Monat liegt, wird der Monatsanfang * dieses Monats als Referenz verwendet, sodass der neue Tag im selben Monat landet. */ - private function calculateNewNextDate(int $aboInterval): \Carbon\Carbon + private function calculateNewNextDate(int $aboInterval): Carbon { $referenceDate = now(); if ($this->model->next_date) { - $currentNextDate = \Carbon\Carbon::parse($this->model->next_date); + $currentNextDate = Carbon::parse($this->model->next_date); if ($currentNextDate->format('Y-m') > now()->format('Y-m')) { $referenceDate = $currentNextDate->startOfMonth(); diff --git a/app/Repositories/CreditRepository.php b/app/Repositories/CreditRepository.php index 5c9b25a9..c88f96c5 100644 --- a/app/Repositories/CreditRepository.php +++ b/app/Repositories/CreditRepository.php @@ -25,8 +25,8 @@ class CreditRepository extends BaseRepository public function create($request = []) { - // need invoice $data - $number = Credit::getCreditNumber(); + // Nummer atomar reservieren (gemeinsamer Nummernkreis mit Rechnungen) + $number = Credit::makeNextCreditNumber(); $credit_date = isset($request['credit_date']) ? $request['credit_date'] : \Carbon::now()->format('d.m.Y'); $credit_send_mail = isset($request['credit_send_mail']) ? true : false; $credit_number = Credit::createCreditNumber($number, $credit_date); @@ -131,8 +131,6 @@ class CreditRepository extends BaseRepository private function finishUserCredit($user_credit_id, $user_credit_items) { - // next credits - Credit::makeNextCreditNumber(); // mark as payed // $UserCreditItems = UserCreditItem::where('user_id', $this->model->id)->wherePaid(false)->get(); foreach ($user_credit_items as $user_credit_item) { diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php index c3d9de8e..d5953a7c 100644 --- a/app/Repositories/UserRepository.php +++ b/app/Repositories/UserRepository.php @@ -2,20 +2,16 @@ namespace App\Repositories; -use Util; -use Request; -use App\User; -use Validator; -use App\Services\UserUtil; -use App\Models\UserAccount; use App\Models\PaymentMethod; +use App\Models\UserAccount; +use App\Services\UserUtil; +use App\Services\VatIdService; +use App\User; use Illuminate\Support\Facades\Hash; -use App\Http\Controllers\Api\KasController; +use Validator; class UserRepository extends BaseRepository { - - public function __construct(User $model) { $this->model = $model; @@ -24,7 +20,7 @@ class UserRepository extends BaseRepository public function update($data) { - if ($data['user_id'] === "new" || $data['user_id'] == 0) { + if ($data['user_id'] === 'new' || $data['user_id'] == 0) { $this->model = User::create([ 'email' => $data['email'], 'password' => env('APP_KEY'), @@ -35,16 +31,16 @@ class UserRepository extends BaseRepository $this->model = $this->getById($data['user_id']); } - if (!$this->model->account_id) { - $account = new UserAccount(); + if (! $this->model->account_id) { + $account = new UserAccount; } else { $account = $this->model->account; } - $data['same_as_billing'] = !isset($data['same_as_billing']) ? 0 : 1; + $data['same_as_billing'] = ! isset($data['same_as_billing']) ? 0 : 1; $account->fill($data)->save(); - if (!$this->model->account_id) { + if (! $this->model->account_id) { $this->model->account_id = $account->id; $this->model->save(); } @@ -73,7 +69,6 @@ class UserRepository extends BaseRepository $this->model->payment_methods = PaymentMethod::getDefaultAsArray(); $this->model->save(); - return $this->model; } @@ -91,31 +86,37 @@ class UserRepository extends BaseRepository { if (isset($data['action']) && $data['action'] == 'reverse_charge_validate') { - $rules = array( + $rules = [ 'tax_identification_number' => 'required', - ); + ]; $validator = Validator::make($data, $rules); if ($validator->fails()) { $data = [ 'user' => $user, ]; - return redirect($route . '#user-vat-validation')->withErrors($validator)->withInput($data); + + return redirect($route.'#user-vat-validation')->withErrors($validator)->withInput($data); } $ret = $this->reverse_charge_activate($data, $user); - if ($ret === 'error') { + if ($ret === 'error' || $ret === 'error_german') { $validator = Validator::make($data, []); - $validator->errors()->add('tax_identification_number_validated', __('msg.VATID_could_not_be_validated')); + $message = $ret === 'error_german' + ? __('msg.VATID_german_not_allowed') + : __('msg.VATID_could_not_be_validated'); + $validator->errors()->add('tax_identification_number_validated', $message); $data['reverse_charge'] = 0; $data = [ 'user' => $user, ]; - return redirect($route . '#user-vat-validation')->withErrors($validator)->withInput($data); + + return redirect($route.'#user-vat-validation')->withErrors($validator)->withInput($data); } if ($ret === 'valid') { \Session()->flash('alert-success', __('msg.VATID_successfully_entered')); - return redirect($route . '#user-vat-validation')->withInput($data); - return redirect($route . '#user-vat-validation')->withInput($data); + return redirect($route.'#user-vat-validation')->withInput($data); + + return redirect($route.'#user-vat-validation')->withInput($data); } } } @@ -125,12 +126,13 @@ class UserRepository extends BaseRepository if (isset($data['action']) && $data['action'] == 'reverse_charge_delete') { $user->account->tax_identification_number = ''; $user->account->reverse_charge = 0; - $user->account->reverse_charge_code = null; + $user->account->reverse_charge_code = null; $user->account->reverse_charge_valid = null; $user->account->save(); $data['tax_identification_number'] = ''; \Session()->flash('alert-success', __('msg.reverse_charge_procedure_and_VATID_deleted')); - return redirect($route . '#user-vat-validation')->withInput($data); + + return redirect($route.'#user-vat-validation')->withInput($data); } } @@ -165,42 +167,30 @@ class UserRepository extends BaseRepository 'SI' => 'SI-Slowenien', 'SK' => 'SK-Slowakei', 'XI' => 'XI-Nordirland', */ - $countryCode = 'DE'; + $accountCountryCode = 'DE'; if ($user->account->country_id) { - $countryCode = $user->account->country->code; + $accountCountryCode = $user->account->country->code; } - $vatid = str_replace(array(' ', '.', '-', ',', ', '), '', trim($data['tax_identification_number'])); - $cc = substr($vatid, 0, 2); - $vatNo = substr($vatid, 2); + $vatService = app(VatIdService::class); + $vatid = $vatService->normalize($data['tax_identification_number']); + $countryCode = $vatService->extractCountryCode($vatid, $accountCountryCode); - $options = [ - 'cache_wsdl' => WSDL_CACHE_NONE, - 'trace' => 1, - 'stream_context' => stream_context_create( - [ - 'ssl' => [ - 'verify_peer' => false, - 'verify_peer_name' => false, - 'allow_self_signed' => true - ] - ] - ) - ]; + if ($vatService->isGermanVatId($vatid) || $countryCode === 'DE') { + return 'error_german'; + } - $client = new \SoapClient("https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl", $options); - $result = $client->checkVat(['countryCode' => $countryCode, 'vatNumber' => $vatNo]); - - if ($result->valid == true) { + if ($vatService->checkVies($vatid, $accountCountryCode) === VatIdService::CHECK_VALID) { $user->account->tax_identification_number = $data['tax_identification_number']; $user->account->reverse_charge = 1; $user->account->reverse_charge_code = $countryCode; $user->account->reverse_charge_valid = now(); $user->account->save(); + return 'valid'; - } else { - return 'error'; } + + return 'error'; } } diff --git a/app/Services/Abo/PaylineForecastService.php b/app/Services/Abo/PaylineForecastService.php new file mode 100644 index 00000000..742b2e5d --- /dev/null +++ b/app/Services/Abo/PaylineForecastService.php @@ -0,0 +1,250 @@ +id}_{$month}_{$year}"; + $cached = Cache::get($cacheKey); + if (is_array($cached)) { + return $cached; + } + + [$context, $failed] = $this->buildPaylineContext($user, $month, $year); + + if (! $failed) { + Cache::put($cacheKey, $context, now()->addHours(self::CACHE_HOURS)); + } + + return $context; + } + + /** + * B8: Abo-Gesamtumsatz in der Payline — Soll und Ist, plus die Anzahl der + * dahinterstehenden Abos (Berater- und Kundenabos, Kundenwunsch 2026-07-24). + * + * @return array{paylines: int, members: int, target: float, actual: float, team_abo_count: int, team_customer_abo_count: int} + */ + public function aboTurnover(User $user, int $month, int $year): array + { + $context = $this->paylineContext($user, $month, $year); + $memberIds = $context['member_ids']; + + return [ + 'paylines' => $context['paylines'], + 'members' => count($memberIds), + 'target' => $this->aboTargetPoints($memberIds), + 'actual' => $this->aboActualPoints($memberIds, $month, $year), + 'team_abo_count' => $this->aboCount($memberIds, 'me', 'user_id'), + 'team_customer_abo_count' => $this->aboCount($memberIds, 'ot', 'member_id'), + ]; + } + + /** + * Anzahl aktiver Abos der Payline-Mitglieder (gleiche Filter wie Soll). + * + * @param int[] $memberIds + */ + private function aboCount(array $memberIds, string $isFor, string $userColumn): int + { + if ($memberIds === []) { + return 0; + } + + return UserAbo::query() + ->where('active', true) + ->whereNotIn('status', [4, 5, 6]) + ->where('is_for', $isFor) + ->whereIn($userColumn, $memberIds) + ->count(); + } + + /** + * B10: Prognose je Ausführungstermin des LAUFENDEN Monats. Basis ist der + * heutige Stand (Kundenumsatz + Payline-Punkte, vom Aufrufer übergeben); + * dazu kommen kumuliert die Abo-Punkte aller noch offenen Ausführungen bis + * zum jeweiligen Termin (next_date > heute, im laufenden Monat). Bereits + * ausgeführte Abos stecken im Ist-Umsatz und werden nicht doppelt gezählt. + * + * @return array{base: float, days: array} + */ + public function forecastDays(User $user, float $basePoints): array + { + $today = Carbon::today(); + $context = $this->paylineContext($user, (int) $today->month, (int) $today->year); + + $upcomingAbos = $this->upcomingAboQuery($user, $context['member_ids'], $today) + ->with('user_abo_items.product') + ->get(); + + $pointsByDay = []; + foreach ($upcomingAbos as $abo) { + $day = (int) Carbon::parse($abo->getRawOriginal('next_date'))->day; + $pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints(); + } + + $days = []; + foreach (UserAbo::$aboDeliveryDays as $deliveryDay) { + $upcoming = 0.0; + foreach ($pointsByDay as $day => $points) { + if ($day <= $deliveryDay) { + $upcoming += $points; + } + } + + $days[] = [ + 'day' => $deliveryDay, + 'date' => $today->copy()->startOfMonth()->addDays($deliveryDay - 1)->format(\Util::formatDateDB()), + 'upcoming' => $upcoming, + 'total' => $basePoints + $upcoming, + 'is_past' => $deliveryDay < (int) $today->day, + ]; + } + + return [ + 'base' => $basePoints, + 'days' => $days, + ]; + } + + /** + * @return array{0: array{paylines: int, member_ids: int[]}, 1: bool} [Kontext, Lauf fehlgeschlagen] + */ + private function buildPaylineContext(User $user, int $month, int $year): array + { + $snapshot = $this->commissionService->snapshot($user, $month, $year); + $paylines = $snapshot['paylines']; + + if ($paylines <= 0) { + return [['paylines' => 0, 'member_ids' => []], (bool) ($snapshot['failed'] ?? false)]; + } + + $memberIds = collect($this->dashboardService->lineBuckets($user->id)) + ->filter(fn ($users, int $line) => $line <= $paylines) + ->flatMap(fn ($users) => $users->pluck('id')) + ->map(fn ($id) => (int) $id) + ->values() + ->all(); + + return [['paylines' => $paylines, 'member_ids' => $memberIds], false]; + } + + /** + * Soll: Abo-Punkte-Bestand (Produktpunkte × Menge, Hauptartikel comp = 0) + * aller aktiven Abos der Payline-Mitglieder — Berater-Abos und Kundenabos. + * + * @param int[] $memberIds + */ + private function aboTargetPoints(array $memberIds): float + { + if ($memberIds === []) { + return 0.0; + } + + return (float) UserAbo::query() + ->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id') + ->join('products', 'products.id', '=', 'user_abo_items.product_id') + ->whereNull('products.deleted_at') + ->where('user_abos.active', true) + ->whereNotIn('user_abos.status', [4, 5, 6]) + ->where('user_abo_items.comp', 0) + ->where(function (Builder $query) use ($memberIds) { + $query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $memberIds)) + ->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $memberIds)); + }) + ->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points') + ->value('points'); + } + + /** + * Ist: im Monat abgerechnete Abo-Punkte der Payline-Mitglieder. + * + * @param int[] $memberIds + */ + private function aboActualPoints(array $memberIds, int $month, int $year): float + { + if ($memberIds === []) { + return 0.0; + } + + return (float) UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_id', $memberIds) + ->where('month', $month) + ->where('year', $year) + ->selectRaw(SalesVolumePoints::aboPoints().' as points') + ->value('points'); + } + + /** + * Noch offene Abo-Ausführungen des laufenden Monats: aktive Abos der + * Payline-Mitglieder plus — je nach {@see INCLUDE_OWN_ABOS} — die eigenen + * Abos/Kundenabos des Betrachters. + * + * @param int[] $memberIds + */ + private function upcomingAboQuery(User $user, array $memberIds, Carbon $today): Builder + { + $aboUserIds = self::INCLUDE_OWN_ABOS + ? array_values(array_unique(array_merge([(int) $user->id], $memberIds))) + : $memberIds; + + return UserAbo::query() + ->where('active', true) + ->where('status', 2) + ->where('next_date', '>', $today->copy()->endOfDay()) + ->where('next_date', '<=', $today->copy()->endOfMonth()) + ->where(function (Builder $query) use ($aboUserIds) { + $query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $aboUserIds)) + ->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $aboUserIds)); + }); + } +} diff --git a/app/Services/Abo/TeamAboOverviewService.php b/app/Services/Abo/TeamAboOverviewService.php index dc798389..309d84d2 100644 --- a/app/Services/Abo/TeamAboOverviewService.php +++ b/app/Services/Abo/TeamAboOverviewService.php @@ -3,9 +3,14 @@ namespace App\Services\Abo; use App\Models\UserAbo; +use App\Models\UserSalesVolume; +use App\Services\AboHelper; use App\Services\Backoffice\BackofficeDashboardService; +use App\Services\Backoffice\SalesVolumePoints; +use App\Services\HTMLHelper; use App\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; /** @@ -59,6 +64,10 @@ class TeamAboOverviewService { $teamUserIds = $this->dashboardService->activeTeamUserIds($user->id); + // 1000-Punkte-Shop: der Berater selbst zählt mit (Kundenwunsch), alle + // übrigen Team-Kennzahlen bleiben ohne den Betrachter. + $shop1000UserIds = array_merge([(int) $user->id], $teamUserIds); + if ($teamUserIds === []) { return [ 'team_total_abos' => 0, @@ -66,7 +75,7 @@ class TeamAboOverviewService 'team_partner_abos_new' => 0, 'team_customer_abos' => 0, 'team_customer_abos_new' => 0, - 'shop_1000' => 0, + 'shop_1000' => $this->dashboardService->shop1000Count($shop1000UserIds, $month, $year), 'new_partners' => 0, ]; } @@ -80,7 +89,7 @@ class TeamAboOverviewService 'team_partner_abos_new' => $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year), 'team_customer_abos' => $customerAbos, 'team_customer_abos_new' => $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year), - 'shop_1000' => $this->dashboardService->shop1000Count($teamUserIds, $month, $year), + 'shop_1000' => $this->dashboardService->shop1000Count($shop1000UserIds, $month, $year), 'new_partners' => $this->dashboardService->newPartnersCount($teamUserIds, $month, $year), ]; } @@ -117,7 +126,7 @@ class TeamAboOverviewService public function monthlyGrowth(User $user, int $year): array { $teamUserIds = $this->dashboardService->activeTeamUserIds($user->id); - $monthNames = \App\Services\HTMLHelper::getTransMonths(); + $monthNames = HTMLHelper::getTransMonths(); $ownByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'user_id', 'me', $year); $customerByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'member_id', 'ot', $year); @@ -191,7 +200,7 @@ class TeamAboOverviewService */ public function memberAboSummary(User $owner, int $memberId): ?array { - if (! \App\Services\AboHelper::isUserInTeam($owner->id, $memberId)) { + if (! AboHelper::isUserInTeam($owner->id, $memberId)) { return null; } @@ -245,7 +254,7 @@ class TeamAboOverviewService } /** - * @param \Illuminate\Support\Collection $users + * @param Collection $users * @return array */ private function buildLineRow(int $line, Collection $users, int $month, int $year): array @@ -303,6 +312,8 @@ class TeamAboOverviewService ]; } + $billedPoints = $this->aboBilledPoints($userIds, $month, $year); + return [ 'line' => $line, 'label' => __('abo.team_line', ['line' => $line]), @@ -315,16 +326,80 @@ class TeamAboOverviewService 'customer_abos' => $customerAbosCount, 'customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year), 'customer_abo_points' => $customerAboPoints, + 'abo_points_billed' => $billedPoints, + 'abo_points_expected' => $billedPoints + $this->upcomingAboPointsUntilLastDeliveryDay($userIds, $month, $year), 'total_abos' => $ownAbosCount + $customerAbosCount, 'members' => $members, ]; } + /** + * Im Monat bereits abgerechnete Abo-Punkte (Eigen- und Kundenabos) der + * übergebenen Berater — gleiche Zählweise wie der Payline-Ist-Wert + * ({@see PaylineForecastService::aboActualPoints()}). + * + * @param int[] $userIds + */ + private function aboBilledPoints(array $userIds, int $month, int $year): float + { + if ($userIds === []) { + return 0.0; + } + + return (float) UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->selectRaw(SalesVolumePoints::aboPoints().' as points') + ->value('points'); + } + + /** + * Abo-Punkte der noch offenen Ausführungen bis einschließlich zum letzten + * Liefertag (25.) des Monats: aktive Abos der Berater mit next_date nach + * heute und im angefragten Monat. Für vergangene/zukünftige Monate 0 — + * die Prognose ergibt nur für den laufenden Monat Sinn. + * + * @param int[] $userIds + */ + private function upcomingAboPointsUntilLastDeliveryDay(array $userIds, int $month, int $year): float + { + $today = Carbon::today(); + + if ($userIds === [] || (int) $today->month !== $month || (int) $today->year !== $year) { + return 0.0; + } + + $lastDeliveryDay = (int) max(UserAbo::$aboDeliveryDays); + $windowEnd = Carbon::create($year, $month, 1)->addDays($lastDeliveryDay - 1)->endOfDay(); + + if ($windowEnd->lte($today->copy()->endOfDay())) { + return 0.0; + } + + return (float) UserAbo::query() + ->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id') + ->join('products', 'products.id', '=', 'user_abo_items.product_id') + ->whereNull('products.deleted_at') + ->where('user_abos.active', true) + ->where('user_abos.status', 2) + ->where('user_abo_items.comp', 0) + ->where('user_abos.next_date', '>', $today->copy()->endOfDay()) + ->where('user_abos.next_date', '<=', $windowEnd) + ->where(function (Builder $query) use ($userIds) { + $query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $userIds)) + ->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $userIds)); + }) + ->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points') + ->value('points'); + } + /** * Eigene (me) aktive Abos je Berater, indiziert nach user_id (ein Abo pro Berater). * * @param int[] $userIds - * @return \Illuminate\Support\Collection + * @return Collection */ private function ownAbosByUser(array $userIds): Collection { @@ -341,7 +416,7 @@ class TeamAboOverviewService * Kundenabos (ot) je Berater, gruppiert nach member_id. * * @param int[] $userIds - * @return \Illuminate\Support\Collection> + * @return Collection> */ private function customerAbosByMember(array $userIds): Collection { @@ -399,6 +474,8 @@ class TeamAboOverviewService 'customer_abos' => 0, 'customer_abos_new' => 0, 'customer_abo_points' => 0.0, + 'abo_points_billed' => 0.0, + 'abo_points_expected' => 0.0, 'total_abos' => 0, 'members' => [], ]; @@ -411,7 +488,7 @@ class TeamAboOverviewService */ private function addToTotals(array $totals, array $row): array { - foreach (['consultants', 'members_total', 'members_with_own_abo', 'own_abos', 'own_abos_new', 'own_abo_points', 'customer_abos', 'customer_abos_new', 'customer_abo_points', 'total_abos'] as $key) { + foreach (['consultants', 'members_total', 'members_with_own_abo', 'own_abos', 'own_abos_new', 'own_abo_points', 'customer_abos', 'customer_abos_new', 'customer_abo_points', 'abo_points_billed', 'abo_points_expected', 'total_abos'] as $key) { $totals[$key] += $row[$key]; } diff --git a/app/Services/AboHelper.php b/app/Services/AboHelper.php index f0661516..8025d77d 100644 --- a/app/Services/AboHelper.php +++ b/app/Services/AboHelper.php @@ -32,6 +32,26 @@ class AboHelper */ public const DEFAULT_ONETIME_WINDOW_DAYS = 4; + /** + * Standard-Mindestlaufzeit (bezahlte Lieferungen) bis zur Kündigung, falls + * das Setting `abo-min-duration` nicht gepflegt ist. + */ + public const DEFAULT_ABO_MIN_DURATION = 6; + + /** + * Abo-Status, die ein Abo endgültig beenden (4 = gekündigt, 5 = beendet). + * Solche Abos blockieren die Anlage eines neuen Abos nicht mehr + * (Ein-Abo-Regel gilt nur für laufende Abos). + */ + public const ENDED_STATUSES = [4, 5]; + + /** + * Standard-Abstand (Monate) zwischen zwei Pausen, falls das Setting + * `abo-pause-interval-months` nicht gepflegt ist (max. 1 Pause pro X Monate, + * rollierend ab der letzten Pause). + */ + public const DEFAULT_PAUSE_INTERVAL_MONTHS = 6; + public static $txaction_filter_text = [ 'paid' => 'paymend_paid', 'appointed' => 'paymend_open', @@ -48,7 +68,7 @@ class AboHelper { $user = $user ? $user : \Auth::user(); - return UserAbo::where('user_id', $user->id)->where('is_for', 'me')->where('status', '>=', 1)->first() === null ? false : true; + return UserAbo::where('user_id', $user->id)->where('is_for', 'me')->where('status', '>=', 1)->whereNotIn('status', self::ENDED_STATUSES)->first() === null ? false : true; } public static function memberHasAbo(ShoppingUser $shopping_user) @@ -57,12 +77,12 @@ class AboHelper return false; } - return UserAbo::where('email', $shopping_user->billing_email)->where('is_for', 'ot')->where('status', '>=', 1)->first() === null ? false : true; + return UserAbo::where('email', $shopping_user->billing_email)->where('is_for', 'ot')->where('status', '>=', 1)->whereNotIn('status', self::ENDED_STATUSES)->first() === null ? false : true; } public static function hasAboByEmail($email) { - return UserAbo::where('email', $email)->where('status', '>=', 1)->first() === null ? false : true; + return UserAbo::where('email', $email)->where('status', '>=', 1)->whereNotIn('status', self::ENDED_STATUSES)->first() === null ? false : true; } public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false) @@ -97,7 +117,9 @@ class AboHelper public static function getAboMinDuration() { - return Setting::getContentBySlug('abo-min-duration'); + $minDuration = Setting::getContentBySlug('abo-min-duration'); + + return $minDuration === false || $minDuration === null || $minDuration === '' ? self::DEFAULT_ABO_MIN_DURATION : $minDuration; } public static function canCancelAbo(UserAbo $user_abo, $view = 'user') @@ -120,6 +142,59 @@ class AboHelper return ! self::canCancelAbo($user_abo, $view); } + /** + * Konfigurierter Mindestabstand (Monate) zwischen zwei Pausen. + */ + public static function getPauseIntervalMonths(): int + { + $months = (int) Setting::getContentBySlug('abo-pause-interval-months'); + + return $months > 0 ? $months : self::DEFAULT_PAUSE_INTERVAL_MONTHS; + } + + /** + * Darf das Abo aktuell um einen Monat pausiert werden? + * Voraussetzungen (außer Admin): Abo läuft (aktiv, Status 2), Mindestlaufzeit + * erreicht (gleiche Basis wie die Kündigung, Setting `abo-min-duration`) und + * die letzte Pause liegt mindestens {@see getPauseIntervalMonths()} Monate + * zurück (rollierend). Die Sperrfrist vor der Ausführung + * (`abo-lock-days-pause-cancel`) wird separat in der Pause-Aktion geprüft. + */ + public static function canPauseAbo(UserAbo $user_abo, $view = 'user'): bool + { + if (! $user_abo->active || (int) $user_abo->status !== 2) { + return false; + } + + if ($view === 'admin') { + return true; + } + + if (! self::canCancelAbo($user_abo, $view)) { + return false; + } + + return self::nextPossiblePauseDate($user_abo) === null; + } + + /** + * Frühestes Datum, ab dem wieder pausiert werden darf — oder null, wenn die + * Pause bereits jetzt möglich ist (keine oder ausreichend lange zurückliegende + * letzte Pause). Dient auch dem UI-Hinweis "wieder möglich ab". + */ + public static function nextPossiblePauseDate(UserAbo $user_abo): ?Carbon + { + $lastPauseDate = $user_abo->getRawOriginal('last_pause_date'); + + if (! $lastPauseDate) { + return null; + } + + $nextPossible = Carbon::parse($lastPauseDate)->startOfDay()->addMonthsNoOverflow(self::getPauseIntervalMonths()); + + return $nextPossible->lte(Carbon::today()) ? null : $nextPossible; + } + /** * Prüft, ob das Bearbeiten des Abos (Liefertag/Intervall) aktuell durch die * Sperrfrist vor der Ausführung blockiert ist. Admins umgehen die Sperre. diff --git a/app/Services/Backoffice/BackofficeDashboardService.php b/app/Services/Backoffice/BackofficeDashboardService.php index 0c263c39..e1c244a6 100644 --- a/app/Services/Backoffice/BackofficeDashboardService.php +++ b/app/Services/Backoffice/BackofficeDashboardService.php @@ -50,7 +50,11 @@ class BackofficeDashboardService ->first(); if ($snapshot) { - return $this->withMeta($snapshot->payload, 'snapshot', $snapshot->calculated_at?->format('d.m.Y H:i')); + return $this->withMeta( + $this->overlayCurrentAboStock($user, $snapshot->payload), + 'snapshot', + $snapshot->calculated_at?->format('d.m.Y H:i') + ); } } @@ -89,6 +93,10 @@ class BackofficeDashboardService $totals = $this->addToTotals($totals, $row); } + // 1000-Punkte-Shop: der VIP selbst zählt mit (Kundenwunsch), obwohl er in + // keiner Linie steckt — nur diese Kennzahl bekommt ihn zusätzlich. + $totals['shop_1000'] += $this->shop1000Count([(int) $user->id], $month, $year); + $totals['label'] = 'Summe'; return [ @@ -97,11 +105,91 @@ class BackofficeDashboardService 'metric_labels' => $this->metricLabels(), 'lines' => $lines, 'totals' => $totals, - 'new_customer_abos' => $this->newCustomerAboRanking($lineBuckets, $month, $year), + 'non_payline_points' => $this->nonPaylinePointsSum($totals['user_ids'], $month, $year), + 'new_customer_abos' => $this->newCustomerAboRanking($user, $lineBuckets, $month, $year), 'leader_abos' => $this->leaderAboOverview($lineBuckets), + 'top_customer_abos' => $this->topCustomerAboRanking($user, $lineBuckets, $month, $year), ]; } + /** + * Überlagert einen Snapshot-Payload mit den tagesaktuellen Abo-Bestandszahlen + * (Kundenentscheid F7): Teamabos/Teamkundenabos je Linie + Summe sowie die + * Führungskräfte- und Top-10-Listen sind Bestands-Kennzahlen ohne Monatsbezug + * und werden live nachgerechnet. Monatsbezogene Werte (Punkte, Umsätze, + * Neupartner, "+N neu", neue Kundenabos) bleiben eingefroren. + * + * @param array $payload + * @return array + */ + private function overlayCurrentAboStock(User $user, array $payload): array + { + $lineBuckets = $this->lineBuckets($user->id); + + $totalPartnerAbos = 0; + $totalCustomerAbos = 0; + + $snapshotLines = collect($payload['lines'] ?? []) + ->map(fn ($row, $key) => (int) ($row['line'] ?? $key)) + ->values() + ->all(); + + foreach ($payload['lines'] ?? [] as $key => $row) { + $line = (int) ($row['line'] ?? $key); + $userIds = ($lineBuckets[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $partnerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(); + $customerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(); + + $payload['lines'][$key]['team_partner_abos'] = $partnerAbos; + $payload['lines'][$key]['team_customer_abos'] = $customerAbos; + + $totalPartnerAbos += $partnerAbos; + $totalCustomerAbos += $customerAbos; + } + + // Linien, die erst nach dem Snapshot entstanden sind (Team ist heute tiefer), + // tragen ebenfalls aktuellen Abo-Bestand: als neue Zeile anhängen, Monatswerte 0. + foreach ($lineBuckets as $line => $users) { + if (in_array((int) $line, $snapshotLines, true)) { + continue; + } + + $userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all(); + if ($userIds === []) { + continue; + } + + $partnerAbos = $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(); + $customerAbos = $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(); + + if ($partnerAbos === 0 && $customerAbos === 0) { + continue; + } + + $row = $this->emptyLine((int) $line, $userIds); + $row['team_partner_abos'] = $partnerAbos; + $row['team_customer_abos'] = $customerAbos; + $payload['lines'][(int) $line] = $row; + + $totalPartnerAbos += $partnerAbos; + $totalCustomerAbos += $customerAbos; + } + + $payload['lines'] = collect($payload['lines'] ?? [])->sortBy('line')->all(); + + if (isset($payload['totals'])) { + $payload['totals']['team_partner_abos'] = $totalPartnerAbos; + $payload['totals']['team_customer_abos'] = $totalCustomerAbos; + } + + $payload['leader_abos'] = $this->leaderAboOverview($lineBuckets); + $payload['top_customer_abos'] = $this->topCustomerAboRanking($user, $lineBuckets, (int) ($payload['month'] ?? now()->month), (int) ($payload['year'] ?? now()->year)); + $payload['abo_stock_live'] = true; + + return $payload; + } + /** * @param array $overview * @return array @@ -123,7 +211,7 @@ class BackofficeDashboardService } /** - * @return array> + * @return array> */ public function lineBuckets(int $rootUserId): array { @@ -206,7 +294,7 @@ class BackofficeDashboardService } /** - * @param \Illuminate\Support\Collection $users + * @param Collection $users * @return array */ private function buildLineRow(int $line, Collection $users, int $month, int $year): array @@ -286,7 +374,7 @@ class BackofficeDashboardService } /** - * @param \Illuminate\Support\Collection $users + * @param Collection $users */ private function activeConsultants(Collection $users): int { @@ -296,7 +384,7 @@ class BackofficeDashboardService } /** - * @param \Illuminate\Support\Collection $users + * @param Collection $users */ private function newPartners(Collection $users, int $month, int $year): int { @@ -376,6 +464,27 @@ class BackofficeDashboardService ]; } + /** + * Summe der Punkte des Teams, die nicht in die Payline fließen (nur-KP-Einträge + * wie Neupartner-Einschreibungspunkte) — erklärt die Differenz zwischen den + * Gesamtpunkten hier und der PP-Summe der Struktur-/Provisionsansicht. + * + * @param int[] $userIds + */ + private function nonPaylinePointsSum(array $userIds, int $month, int $year): float + { + if ($userIds === []) { + return 0.0; + } + + return (float) UserSalesVolume::query() + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->selectRaw(SalesVolumePoints::nonPaylinePoints().' as points') + ->value('points'); + } + /** * Anzahl der Neupartner (aktiver Account, im Monat aktiv geworden). * Konsistent mit der Drilldown-Liste {@see BackofficeDrilldownService::newPartnerRows()}. @@ -404,14 +513,15 @@ class BackofficeDashboardService * Ranking: Wer im Team hat im Monat wie viele neue Kundenabos abgeschlossen? * Sortiert nach Anzahl absteigend (nicht nach Linie), Linie wird je Berater mitgeliefert, * dazu der aktuelle Gesamtbestand an Kundenabos des Beraters. + * Der Statistik-Nutzer selbst zählt mit (Kundenwunsch, Linie 0 / "ich"). * Zählweise identisch zu team_customer_abos_new {@see newAboCount()}. * - * @param array> $lineBuckets - * @return array + * @param array> $lineBuckets + * @return array */ - private function newCustomerAboRanking(array $lineBuckets, int $month, int $year): array + private function newCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array { - $usersByLine = $this->usersWithLine($lineBuckets); + $usersByLine = $this->usersWithLine($lineBuckets, $rootUser); if ($usersByLine === []) { return []; @@ -437,6 +547,7 @@ class BackofficeDashboardService 'name' => $this->userName($entry['user']), 'career_level' => $this->careerLevel($entry['user']), 'line' => $entry['line'], + 'is_self' => $entry['line'] === 0, 'abo_count' => (int) $counts->get($userId), 'total_abo_count' => (int) $totalCounts->get($userId, 0), ]) @@ -447,10 +558,21 @@ class BackofficeDashboardService /** * Abos der Führungskräfte (ab Stufe Vertriebsleiter) im Team: Gesamtzahlen plus - * Liste aller Führungskräfte inklusive Linie, eigener Abos und Kundenabos. + * Liste aller Führungskräfte inklusive Linie, eigener Abos, Kundenabos sowie + * Team-Abos und Team-Kundenabos ihres Teilbaums und der Gesamtsumme aller + * vier Kennzahlen (Kundenwunsch B7). * - * @param array> $lineBuckets - * @return array{level_name: string, total: int, total_own: int, total_customer: int, rows: array} + * Zwei Team-Sichten je Führungskraft (Kundenentscheid 2026-07-24, revidiert F9): + * - team_abo_count / team_customer_abo_count ("direkt"): Teilbaum wird an der + * nächsten Führungskraft abgeschnitten — überlappungsfrei, die Kopf-Summen + * (total_*) bleiben dadurch echte Gesamtwerte ohne Doppelzählung. + * - team_abo_count_total / team_customer_abo_count_total ("gesamt"): komplette + * Downline der Führungskraft; verschachtelte Führungskräfte zählen hier + * mehrfach, deshalb werden diese Spalten nicht aufsummiert. + * total_abo_count_full = eigene + Kundenabos + Team gesamt (Sortierung). + * + * @param array> $lineBuckets + * @return array{level_name: string, total: int, total_own: int, total_customer: int, total_team: int, total_team_customer: int, rows: array} */ private function leaderAboOverview(array $lineBuckets): array { @@ -458,54 +580,227 @@ class BackofficeDashboardService ->where('name', self::LEADER_LEVEL_NAME) ->value('pos'); - $leadersByLine = $leaderMinPos === null ? [] : collect($this->usersWithLine($lineBuckets)) - ->filter(fn (array $entry) => ($entry['user']->user_level?->pos ?? 0) >= $leaderMinPos) + $usersWithLine = $this->usersWithLine($lineBuckets); + $isLeader = fn (User $user): bool => $leaderMinPos !== null && ($user->user_level?->pos ?? 0) >= $leaderMinPos; + + $leadersByLine = $leaderMinPos === null ? [] : collect($usersWithLine) + ->filter(fn (array $entry) => $isLeader($entry['user'])) ->all(); if ($leadersByLine === []) { - return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'rows' => []]; + return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'total_team' => 0, 'total_team_customer' => 0, 'rows' => []]; } $leaderIds = array_keys($leadersByLine); + $teamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, $isLeader); + $fullTeamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, fn (User $user): bool => false); + $allCountIds = array_values(array_unique(array_merge($leaderIds, ...array_values($fullTeamIdsByLeader)))); $ownCounts = $this->activeAboQuery() - ->whereIn('user_id', $leaderIds) + ->whereIn('user_id', $allCountIds) ->where('is_for', 'me') ->selectRaw('user_id, COUNT(*) as abo_count') ->groupBy('user_id') ->pluck('abo_count', 'user_id'); - $customerCounts = $this->customerAboCounts($leaderIds); + $customerCounts = $this->customerAboCounts($allCountIds); $rows = collect($leadersByLine) + ->map(function (array $entry, int $userId) use ($ownCounts, $customerCounts, $teamIdsByLeader, $fullTeamIdsByLeader) { + $teamIds = $teamIdsByLeader[$userId] ?? []; + $fullTeamIds = $fullTeamIdsByLeader[$userId] ?? []; + $own = (int) $ownCounts->get($userId, 0); + $customer = (int) $customerCounts->get($userId, 0); + $team = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0)); + $teamCustomer = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0)); + $teamTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0)); + $teamCustomerTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0)); + + return [ + 'user_id' => $userId, + 'name' => $this->userName($entry['user']), + 'career_level' => $this->careerLevel($entry['user']), + 'line' => $entry['line'], + 'own_abo_count' => $own, + 'customer_abo_count' => $customer, + 'team_abo_count' => $team, + 'team_customer_abo_count' => $teamCustomer, + 'team_abo_count_total' => $teamTotal, + 'team_customer_abo_count_total' => $teamCustomerTotal, + 'total_abo_count' => $own + $customer + $team + $teamCustomer, + 'total_abo_count_full' => $own + $customer + $teamTotal + $teamCustomerTotal, + ]; + }) + ->sortByDesc('total_abo_count_full') + ->values(); + + $totalOwn = (int) $rows->sum('own_abo_count'); + $totalCustomer = (int) $rows->sum('customer_abo_count'); + $totalTeam = (int) $rows->sum('team_abo_count'); + $totalTeamCustomer = (int) $rows->sum('team_customer_abo_count'); + + return [ + 'level_name' => self::LEADER_LEVEL_NAME, + 'total' => $totalOwn + $totalCustomer + $totalTeam + $totalTeamCustomer, + 'total_own' => $totalOwn, + 'total_customer' => $totalCustomer, + 'total_team' => $totalTeam, + 'total_team_customer' => $totalTeamCustomer, + 'rows' => $rows->all(), + ]; + } + + /** + * Teilbaum-User-IDs je Führungskraft aus der gemeinsamen Sponsor-Map + * (ein Durchlauf über die bereits geladene Downline, keine Abfragen pro + * Führungskraft). Der Abstieg stoppt an jedem Kind, für das $isLeader true + * liefert (dieses und sein Team werden übersprungen); mit einem immer-false + * Callable ergibt sich die komplette Downline. + * + * @param array $usersWithLine + * @param int[] $leaderIds + * @return array + */ + private function teamIdsPerLeader(array $usersWithLine, array $leaderIds, callable $isLeader): array + { + $childrenBySponsor = []; + + foreach ($usersWithLine as $entry) { + $childrenBySponsor[(int) $entry['user']->m_sponsor][] = $entry['user']; + } + + $teamIdsByLeader = []; + + foreach ($leaderIds as $leaderId) { + $teamIds = []; + $queue = $childrenBySponsor[$leaderId] ?? []; + + while ($queue !== []) { + $child = array_shift($queue); + + if ($isLeader($child)) { + continue; + } + + $teamIds[] = (int) $child->id; + + foreach ($childrenBySponsor[(int) $child->id] ?? [] as $grandChild) { + $queue[] = $grandChild; + } + } + + $teamIdsByLeader[$leaderId] = $teamIds; + } + + return $teamIdsByLeader; + } + + /** + * Top-10-Ranglisten der Kundenabos im eigenen Team (Kundenwunsch B5/B6, + * "unabhängig vom Incentive" = aktueller Gesamtbestand aktiver Kundenabos): + * + * - by_count: nach Anzahl aktiver Kundenabos je Berater + * - by_points: nach Umsatz mit Ist (im gewählten Monat abgerechnete + * Kundenabo-Punkte aus user_sales_volumes, Hauptwert und Sortierung — + * Kundenentscheid 2026-07-24) und Soll (Abo-Punkte-Bestand, Produktpunkte + * × Menge der laufenden Kundenabos) als Zweitwert + * + * Der Statistik-Nutzer selbst zählt in beiden Listen mit (Linie 0 / "ich"). + * + * @param array> $lineBuckets + * @return array{by_count: array>, by_points: array>} + */ + private function topCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array + { + $usersWithLine = $this->usersWithLine($lineBuckets, $rootUser); + + if ($usersWithLine === []) { + return ['by_count' => [], 'by_points' => []]; + } + + $memberIds = array_keys($usersWithLine); + $aboCounts = $this->customerAboCounts($memberIds); + $targetPoints = $this->customerAboTargetPoints($memberIds); + $actualPoints = $this->customerAboActualPoints($memberIds, $month, $year); + + $rows = collect($usersWithLine) ->map(fn (array $entry, int $userId) => [ 'user_id' => $userId, 'name' => $this->userName($entry['user']), 'career_level' => $this->careerLevel($entry['user']), 'line' => $entry['line'], - 'own_abo_count' => (int) $ownCounts->get($userId, 0), - 'customer_abo_count' => (int) $customerCounts->get($userId, 0), - ]) - ->sortByDesc(fn (array $row) => $row['own_abo_count'] + $row['customer_abo_count']) - ->values(); - - $totalOwn = (int) $rows->sum('own_abo_count'); - $totalCustomer = (int) $rows->sum('customer_abo_count'); + 'is_self' => $entry['line'] === 0, + 'abo_count' => (int) $aboCounts->get($userId, 0), + 'points_target' => (float) $targetPoints->get($userId, 0.0), + 'points_actual' => (float) $actualPoints->get($userId, 0.0), + ]); return [ - 'level_name' => self::LEADER_LEVEL_NAME, - 'total' => $totalOwn + $totalCustomer, - 'total_own' => $totalOwn, - 'total_customer' => $totalCustomer, - 'rows' => $rows->all(), + 'by_count' => $rows + ->filter(fn (array $row) => $row['abo_count'] > 0) + ->sortByDesc('abo_count') + ->take(10) + ->values() + ->all(), + 'by_points' => $rows + ->filter(fn (array $row) => $row['points_target'] > 0 || $row['points_actual'] > 0) + ->sortByDesc('points_actual') + ->take(10) + ->values() + ->all(), ]; } + /** + * Soll-Punkte je Berater: Abo-Punkte-Bestand der aktiven Kundenabos + * (Produktpunkte × Menge, nur Hauptartikel comp = 0 — gleiche Logik wie + * {@see UserAbo::getTotalPoints()}). + * + * @param int[] $memberIds + * @return Collection + */ + private function customerAboTargetPoints(array $memberIds): Collection + { + return UserAbo::query() + ->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id') + ->join('products', 'products.id', '=', 'user_abo_items.product_id') + ->whereNull('products.deleted_at') + ->where('user_abos.active', true) + ->whereNotIn('user_abos.status', [4, 5, 6]) + ->whereIn('user_abos.member_id', $memberIds) + ->where('user_abos.is_for', 'ot') + ->where('user_abo_items.comp', 0) + ->selectRaw('user_abos.member_id, COALESCE(SUM(products.points * user_abo_items.qty), 0) as points') + ->groupBy('user_abos.member_id') + ->pluck('points', 'member_id') + ->map(fn ($points) => (float) $points); + } + + /** + * Ist-Punkte je Berater: im Monat tatsächlich abgerechnete Kundenabo-Punkte. + * + * @param int[] $memberIds + * @return Collection + */ + private function customerAboActualPoints(array $memberIds, int $month, int $year): Collection + { + return UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_id', $memberIds) + ->where('month', $month) + ->where('year', $year) + ->select('user_sales_volumes.user_id') + ->selectRaw(SalesVolumePoints::customerAboPoints().' as points') + ->groupBy('user_sales_volumes.user_id') + ->pluck('points', 'user_id') + ->map(fn ($points) => (float) $points); + } + /** * Aktueller Bestand aktiver Kundenabos je Berater (member_id). * * @param int[] $memberIds - * @return \Illuminate\Support\Collection + * @return Collection */ private function customerAboCounts(array $memberIds): Collection { @@ -522,13 +817,20 @@ class BackofficeDashboardService } /** - * @param array> $lineBuckets - * @return array + * Downline-User mit ihrer Linie; optional inklusive des Statistik-Nutzers + * selbst als Linie 0 (er steckt in keinem lineBucket, {@see lineBuckets()}). + * + * @param array> $lineBuckets + * @return array */ - private function usersWithLine(array $lineBuckets): array + private function usersWithLine(array $lineBuckets, ?User $rootUser = null): array { $usersByLine = []; + if ($rootUser !== null) { + $usersByLine[(int) $rootUser->id] = ['user' => $rootUser, 'line' => 0]; + } + foreach ($lineBuckets as $line => $users) { foreach ($users as $user) { $usersByLine[(int) $user->id] = ['user' => $user, 'line' => $line]; diff --git a/app/Services/Backoffice/BackofficeDrilldownService.php b/app/Services/Backoffice/BackofficeDrilldownService.php index c4ac5a7b..5830cb6a 100644 --- a/app/Services/Backoffice/BackofficeDrilldownService.php +++ b/app/Services/Backoffice/BackofficeDrilldownService.php @@ -7,15 +7,16 @@ use App\Models\UserSalesVolume; use App\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; class BackofficeDrilldownService { public function __construct(private BackofficeDashboardService $dashboardService) {} /** - * @return array{metric: string, metric_label: string, line: int, line_label: string, month: int, year: int, rows: array>, summary: array} + * @return array{metric: string, metric_label: string, only_new: bool, line: int, line_label: string, month: int, year: int, rows: array>, summary: array} */ - public function details(User $viewer, int $line, string $metric, int $month, int $year): array + public function details(User $viewer, int $line, string $metric, int $month, int $year, bool $onlyNew = false): array { $metricLabels = $this->dashboardService->metricLabels(); @@ -23,19 +24,31 @@ class BackofficeDrilldownService abort(404); } + $onlyNew = $onlyNew && in_array($metric, ['team_partner_abos', 'team_customer_abos'], true); + $userIds = $this->dashboardService->lineUserIds($viewer->id, $line); + + if ($metric === 'shop_1000' && $line === 0) { + $userIds[] = (int) $viewer->id; + } + $rows = match ($metric) { 'consultants' => $this->consultantRows($userIds), 'new_partners' => $this->newPartnerRows($userIds, $month, $year), - 'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year), - 'team_customer_abos' => $this->customerAboRows($userIds, $month, $year), + 'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew), + 'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew), 'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric), default => [], }; + if ($metric === 'shop_1000') { + $rows = $this->markViewerRow($rows, (int) $viewer->id); + } + return [ 'metric' => $metric, - 'metric_label' => $metricLabels[$metric], + 'metric_label' => $metricLabels[$metric].($onlyNew ? ' – nur neue' : ''), + 'only_new' => $onlyNew, 'line' => $line, 'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien', 'month' => $month, @@ -46,8 +59,26 @@ class BackofficeDrilldownService } /** + * Kennzeichnet die eigene Zeile des Betrachters (der VIP zählt beim + * 1000-Punkte-Shop selbst mit und soll in der Liste erkennbar sein). + * * @param array> $rows - * @return array{count: int, abo_count: int, points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int} + * @return array> + */ + private function markViewerRow(array $rows, int $viewerId): array + { + return array_map(function (array $row) use ($viewerId): array { + if ((int) ($row['user_id'] ?? 0) === $viewerId) { + $row['name'] .= ' (ich)'; + } + + return $row; + }, $rows); + } + + /** + * @param array> $rows + * @return array{count: int, abo_count: int, points: float, customer_points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int} */ private function summary(array $rows): array { @@ -55,6 +86,7 @@ class BackofficeDrilldownService 'count' => count($rows), 'abo_count' => (int) collect($rows)->sum('abo_count'), 'points' => (float) collect($rows)->sum('points'), + 'customer_points' => (float) collect($rows)->sum('customer_points'), 'own_points' => (float) collect($rows)->sum('own_points'), 'external_points' => (float) collect($rows)->sum('external_points'), 'customer_abo_points' => (float) collect($rows)->sum('customer_abo_points'), @@ -102,7 +134,7 @@ class BackofficeDrilldownService $startDate = Carbon::create($year, $month, 1)->startOfMonth(); $endDate = Carbon::create($year, $month, 1)->endOfMonth(); - return User::query() + $partners = User::query() ->with(['account', 'user_level']) ->whereIn('id', $userIds) ->whereNotNull('m_level') @@ -111,12 +143,25 @@ class BackofficeDrilldownService ->orderBy('active_date') ->get() ->filter(fn (User $user) => Carbon::parse($user->payment_account)->isFuture()) + ->values(); + + $sponsors = User::query() + ->with('account') + ->whereIn('id', $partners->pluck('m_sponsor')->filter()->unique()) + ->get() + ->keyBy('id'); + + $customerPoints = $this->externalPointsByUser($partners->pluck('id')->map(fn ($id) => (int) $id)->all(), $month, $year); + + return $partners ->map(fn (User $user) => [ 'type' => 'user', 'user_id' => $user->id, 'name' => $this->userName($user), 'email' => $user->email, 'career_level' => $this->careerLevel($user), + 'sponsor' => $user->m_sponsor && $sponsors->has((int) $user->m_sponsor) ? $this->userName($sponsors->get((int) $user->m_sponsor)) : '-', + 'customer_points' => (float) $customerPoints->get((int) $user->id, 0.0), 'active_date' => $this->formatDate($user->active_date), 'payment_account' => $this->formatDate($user->payment_account), ]) @@ -124,16 +169,41 @@ class BackofficeDrilldownService ->all(); } + /** + * Externe Kundenpunkte (Shop + Abo) je User im gewählten Monat — tagesaktuell + * aus user_sales_volumes summiert (gleiche Status-Logik wie die Übersicht). + * + * @param int[] $userIds + * @return Collection + */ + private function externalPointsByUser(array $userIds, int $month, int $year): Collection + { + if ($userIds === []) { + return collect(); + } + + return UserSalesVolume::query() + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->select('user_id') + ->selectRaw(SalesVolumePoints::externalPoints().' as external_points') + ->groupBy('user_id') + ->pluck('external_points', 'user_id') + ->map(fn ($points) => (float) $points); + } + /** * @param int[] $userIds * @return array> */ - private function partnerAboRows(array $userIds, int $month, int $year): array + private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array { return $this->activeAboQuery() ->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions']) ->whereIn('user_id', $userIds) ->where('is_for', 'me') + ->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year)) ->orderBy('next_date') ->get() ->map(fn (UserAbo $abo) => [ @@ -165,12 +235,13 @@ class BackofficeDrilldownService * @param int[] $userIds * @return array> */ - private function customerAboRows(array $userIds, int $month, int $year): array + private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array { return $this->activeAboQuery() ->with(['member.account', 'member.user_level', 'user_abo_items.product']) ->whereIn('member_id', $userIds) ->where('is_for', 'ot') + ->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year)) ->orderBy('member_id') ->orderBy('next_date') ->get() @@ -279,6 +350,17 @@ class BackofficeDrilldownService ->whereNotIn('status', [4, 5, 6]); } + /** + * Nur Abos, die im gewählten Monat gestartet sind (gleiche Zählweise wie + * die "+N neu"-Badges, {@see BackofficeDashboardService::newAboCount()}). + */ + private function whereNewInMonth(Builder $query, int $month, int $year): Builder + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + + return $query->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()]); + } + private function userName(User $user): string { $name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? '')); diff --git a/app/Services/Backoffice/SalesVolumePoints.php b/app/Services/Backoffice/SalesVolumePoints.php index 99cfc32d..00f35df1 100644 --- a/app/Services/Backoffice/SalesVolumePoints.php +++ b/app/Services/Backoffice/SalesVolumePoints.php @@ -2,6 +2,8 @@ namespace App\Services\Backoffice; +use App\Services\BusinessPlan\SalesPointsVolume; + /** * Liefert SQL-Aggregat-Ausdrücke für die monatliche Punkte-/Umsatzauswertung * der Backoffice-Statistik. @@ -11,7 +13,7 @@ namespace App\Services\Backoffice; * kumulative Monats-Laufstand zum Zeitpunkt der Bestellung – KEINE Einzelbeträge. * Eine SUM() über diese Spalten zählt mehrfach. Korrekt wird je Zeile die * `points`/`total_net`-Spalte summiert und nach `status` gefiltert – analog zu - * {@see \App\Services\BusinessPlan\SalesPointsVolume::reCalculateSalesPointsVolume()}. + * {@see SalesPointsVolume::reCalculateSalesPointsVolume()}. * * Status-Zuordnung: * - Eigenpunkte (KP): status 1, 4, 5, 6 @@ -59,6 +61,26 @@ class SalesVolumePoints return self::ownPoints().' + '.self::externalPoints(); } + /** + * Punkte aus Abo-Bestellungen (eigene wie Kundenabos) — alle zählbaren Status. + * Benötigt den Join auf shopping_orders. + */ + public static function aboPoints(): string + { + return self::sumWhere(self::TURNOVER_PREDICATE.' AND shopping_orders.is_abo = 1', 'user_sales_volumes.points'); + } + + /** + * Punkte, die nur als Karrierepunkte (KP) des Beraters zählen und NICHT als + * Teampunkte in die Payline fließen (status_points = 2, z. B. + * Neupartner-Einschreibungspunkte, manuelle Gutschriften) — vgl. + * {@see SalesPointsVolume::add_KP_TP_Points()}. + */ + public static function nonPaylinePoints(): string + { + return self::sumWhere(self::OWN_PREDICATE.' AND user_sales_volumes.status_points = 2', 'user_sales_volumes.points'); + } + public static function turnoverNet(): string { return self::sumWhere(self::TURNOVER_PREDICATE, 'user_sales_volumes.total_net'); diff --git a/app/Services/BusinessPlan/LiveCommissionService.php b/app/Services/BusinessPlan/LiveCommissionService.php new file mode 100644 index 00000000..e05bcc9a --- /dev/null +++ b/app/Services/BusinessPlan/LiveCommissionService.php @@ -0,0 +1,145 @@ +id) + ->where('month', $month) + ->where('year', $year) + ->first(); + + if ($stored) { + return $this->buildSnapshot( + (float) ($stored->payline_points ?? 0), + $this->paylinesFromQualLevel($stored->qual_user_level), + (float) ($stored->commission_pp_total ?? 0), + (float) ($stored->commission_shop_sales ?? 0), + (float) ($stored->commission_growth_total ?? 0), + false + ); + } + + $cacheKey = "live_business_{$user->id}_{$month}_{$year}"; + $cached = Cache::get($cacheKey); + if (is_array($cached)) { + return $cached; + } + + $snapshot = $this->calculateLive($user, $month, $year); + if ($snapshot !== null) { + Cache::put($cacheKey, $snapshot, now()->addHours(self::CACHE_HOURS)); + + return $snapshot; + } + + return $this->buildSnapshot(0.0, 0, 0.0, 0.0, 0.0, true, failed: true); + } + + /** + * Provision "Stand heute" (B1) — alle Bestandteile plus Summe. + * + * @return array{total: float, pp: float, shop: float, growth: float, is_live: bool} + */ + public function commission(User $user, int $month, int $year): array + { + $snapshot = $this->snapshot($user, $month, $year); + + return [ + 'total' => round($snapshot['pp'] + $snapshot['shop'] + $snapshot['growth'], 2), + 'pp' => $snapshot['pp'], + 'shop' => $snapshot['shop'], + 'growth' => $snapshot['growth'], + 'is_live' => $snapshot['is_live'], + ]; + } + + /** + * @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool}|null + */ + private function calculateLive(User $user, int $month, int $year): ?array + { + try { + $treeCalcBot = new TreeCalcBotOptimized($month, $year, 'member'); + $treeCalcBot->initBusinesslUserDetail($user); + $businessUser = $treeCalcBot->business_user; + + if (! $businessUser) { + return null; + } + + // Kein `?? 0` auf den Magic-Properties: die Items haben kein __isset, + // isset() wäre dort immer false und das Ergebnis damit immer 0. + return $this->buildSnapshot( + (float) $businessUser->payline_points, + $this->paylinesFromQualLevel($businessUser->qual_user_level), + (float) $businessUser->commission_pp_total, + (float) $businessUser->commission_shop_sales, + (float) $businessUser->commission_growth_total, + true + ); + } catch (\Throwable $e) { + \Log::warning("LiveCommissionService: Live-Berechnung fehlgeschlagen für User {$user->id}: ".$e->getMessage()); + + return null; + } + } + + /** + * Payline-Tiefe (Anzahl bezahlter Ebenen) aus dem Qualifikationslevel. + * + * @param array|object|null $qualUserLevel + */ + private function paylinesFromQualLevel(mixed $qualUserLevel): int + { + if (is_array($qualUserLevel)) { + return (int) ($qualUserLevel['paylines'] ?? 0); + } + + if (is_object($qualUserLevel)) { + return (int) ($qualUserLevel->paylines ?? 0); + } + + return 0; + } + + /** + * @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool} + */ + private function buildSnapshot(float $paylinePoints, int $paylines, float $pp, float $shop, float $growth, bool $isLive, bool $failed = false): array + { + return [ + 'payline_points' => round($paylinePoints, 2), + 'paylines' => $paylines, + 'pp' => round($pp, 2), + 'shop' => round($shop, 2), + 'growth' => round($growth, 2), + 'is_live' => $isLive, + 'failed' => $failed, + ]; + } +} diff --git a/app/Services/BusinessPlan/TreeHtmlRenderer.php b/app/Services/BusinessPlan/TreeHtmlRenderer.php index ed59e7da..35601a3f 100644 --- a/app/Services/BusinessPlan/TreeHtmlRenderer.php +++ b/app/Services/BusinessPlan/TreeHtmlRenderer.php @@ -2,7 +2,9 @@ namespace App\Services\BusinessPlan; +use App\Models\UserAbo; use App\Services\TranslationHelper; +use App\User; /** * Klasse für die HTML-Darstellung von Business-Trees @@ -11,8 +13,16 @@ use App\Services\TranslationHelper; class TreeHtmlRenderer { private string $initFrom; + private bool $forceLiveCalculation; + /** + * Abo-Kennzeichen je User-ID (gebatcht geladen, nicht pro Knoten). + * + * @var array + */ + private array $aboBadges = []; + public function __construct(string $initFrom = 'member', bool $forceLiveCalculation = false) { $this->initFrom = $initFrom; @@ -28,6 +38,8 @@ class TreeHtmlRenderer return '
Keine Business-User gefunden.
'; } + $this->loadAboBadges($this->collectUserIds($businessUsers)); + $html = '
    '; foreach ($businessUsers as $businessUser) { $html .= $this->renderUserItem($businessUser, 0); @@ -46,6 +58,8 @@ class TreeHtmlRenderer return '
    Keine parentlosen User gefunden.
    '; } + $this->loadAboBadges($this->collectUserIds($parentless)); + $html = ''; foreach ($parentless as $item) { $html .= $this->renderParentlessItem($item); @@ -59,14 +73,14 @@ class TreeHtmlRenderer */ public function renderSponsor($sponsor): string { - if (!$sponsor) { - return '
    ' . __('team.no_sponsor_assigned') . '
    '; + if (! $sponsor) { + return '
    '.__('team.no_sponsor_assigned').'
    '; } - return '
  1. ' . - '
    ' . - $this->renderUserInfo($sponsor, false, true) . - '
    ' . + return '
  2. '. + '
    '. + $this->renderUserInfo($sponsor, false, true). + '
    '. '
  3. '; } @@ -91,9 +105,9 @@ class TreeHtmlRenderer /** * Rendert User Sponsor (für UserTeamCalcBot) */ - public function renderUserSponsor(\App\User $sponsor): string + public function renderUserSponsor(User $sponsor): string { - if (!$sponsor || !$sponsor->account) { + if (! $sponsor || ! $sponsor->account) { return '
    Kein Sponsor gefunden.
    '; } @@ -103,19 +117,19 @@ class TreeHtmlRenderer // Sponsor Info $html .= '
    '; - $html .= '' . e($sponsor->account->first_name . ' ' . $sponsor->account->last_name) . '
    '; - $html .= '' . e($sponsor->email) . ''; + $html .= ''.e($sponsor->account->first_name.' '.$sponsor->account->last_name).'
    '; + $html .= ''.e($sponsor->email).''; $html .= '
    '; // Account Info $html .= '
    '; - $html .= '' . e($sponsor->account->m_account ?? '') . ''; + $html .= ''.e($sponsor->account->m_account ?? '').''; $html .= '
    '; // Level Info $html .= '
    '; if ($sponsor->user_level) { - $html .= '' . e($sponsor->user_level->getLang('name')) . ''; + $html .= ''.e($sponsor->user_level->getLang('name')).''; } $html .= '
    '; @@ -136,7 +150,7 @@ class TreeHtmlRenderer */ private function renderTeamMemberItem($member, int $depth): string { - $html = '
  4. '; + $html = '
  5. '; $html .= '
    '; $html .= '
    '; @@ -146,19 +160,19 @@ class TreeHtmlRenderer // Name und Email $html .= '
    '; $html .= $indent; - $html .= '' . e(($member->first_name ?? '') . ' ' . ($member->last_name ?? '')) . '
    '; - $html .= $indent . '' . e($member->email ?? '') . ''; + $html .= ''.e(($member->first_name ?? '').' '.($member->last_name ?? '')).'
    '; + $html .= $indent.''.e($member->email ?? '').''; $html .= '
    '; // Account ID $html .= '
    '; - $html .= '' . e($member->m_account ?? '') . ''; + $html .= ''.e($member->m_account ?? '').''; $html .= '
    '; // Level $html .= '
    '; - if (!empty($member->user_level_name)) { - $html .= '' . e($member->user_level_name) . ''; + if (! empty($member->user_level_name)) { + $html .= ''.e($member->user_level_name).''; if ($member->next_qual_user_level) { $html .= ''; @@ -168,12 +182,12 @@ class TreeHtmlRenderer // Qualifikation $html .= '
    '; - if (!empty($member->qual_kp)) { + if (! empty($member->qual_kp)) { $pointsSum = (float) ($member->sales_volume_points_KP_sum ?? 0); $qualKP = (float) $member->qual_kp; $isQual = $pointsSum >= $qualKP; $badgeClass = $isQual ? 'badge-success' : 'badge-warning'; - $html .= 'KU ' . $qualKP . ''; + $html .= 'KU '.$qualKP.''; } $html .= '
    '; @@ -186,7 +200,7 @@ class TreeHtmlRenderer $html .= '
    '; // Kinder rendern - if (!empty($member->businessUserItems) && is_array($member->businessUserItems)) { + if (! empty($member->businessUserItems) && is_array($member->businessUserItems)) { $html .= '
      '; foreach ($member->businessUserItems as $child) { $html .= $this->renderTeamMemberItem($child, $depth + 1); @@ -213,11 +227,11 @@ class TreeHtmlRenderer $childrenHtml .= '
    '; } - return '
  6. ' . - '
    ' . - $this->renderUserCardWithDepth($item, $deep) . - '
    ' . - $childrenHtml . + return '
  7. '. + '
    '. + $this->renderUserCardWithDepth($item, $deep). + '
    '. + $childrenHtml. '
  8. '; } @@ -226,10 +240,10 @@ class TreeHtmlRenderer */ private function renderParentlessItem($item): string { - return '
  9. ' . - '
    ' . - $this->renderUserInfo($item, true, false) . - '
    ' . + return '
  10. '. + '
    '. + $this->renderUserInfo($item, true, false). + '
    '. '
  11. '; } @@ -240,16 +254,16 @@ class TreeHtmlRenderer { $depthBadge = ''; if ($deep > 0) { - $depthBadge = '
    ' . - '
    ' . $deep . '
    ' . + $depthBadge = '
    '. + '
    '.$deep.'
    '. '
    '; } - return '
    ' . - $depthBadge . - '
    ' . - $this->renderUserInfo($item, false, false) . - '
    ' . + return '
    '. + $depthBadge. + '
    '. + $this->renderUserInfo($item, false, false). + '
    '. '
    '; } @@ -263,35 +277,35 @@ class TreeHtmlRenderer \Log::debug("TreeHtmlRenderer: Rendering user info for user {$item->user_id}"); - $html = ''; + $html = ''; // User Link - $html .= '' . - ' ' . - '' . e($item->first_name . ' ' . $item->last_name) . '' . + $html .= ''. + ' '. + ''.e($item->first_name.' '.$item->last_name).''. ''; // Email - $html .= ' ' . e($item->email) . ''; + $html .= ' '.e($item->email).''; // Optional: Geburtstag $birthday = $item->user_birthday; // Magic Method __get() verwenden if ($birthday && trim($birthday) !== '') { - $html .= ' | ' . e($birthday); + $html .= ' | '.e($birthday); } // Optional: Telefon $phone = $item->user_phone; // Magic Method __get() verwenden if ($phone && trim($phone) !== '') { - $html .= ' | ' . e($phone); + $html .= ' | '.e($phone); } // Level Badge $levelName = $item->user_level_name ? TranslationHelper::transUserLevelName($item->user_level_name) : ''; $account = $item->m_account ?: ''; - $html .= ' ' . e($levelName . ' | ' . $account) . ''; + $html .= ' '.e($levelName.' | '.$account).''; // Karriere-Aufstiegs-Icon für qualifizierte User (nur in Struktur-Ansicht)# @@ -299,15 +313,18 @@ class TreeHtmlRenderer $html .= ''; } + // Abo-Kennzeichen (eigenes Abo + eigene Kundenabos) + $html .= $this->renderAboBadges((int) $item->user_id); + // Details für aktive Accounts if ($item->active_account) { $html .= '
    '; - if (!$isSponsor) { + if (! $isSponsor) { $html .= $this->renderAccountDetails($item); } // Action Button (außer für Sponsor-Ansicht) - if (!$isSponsor && $this->shouldShowActionButton()) { + if (! $isSponsor && $this->shouldShowActionButton()) { $html .= $this->renderActionButton($item->user_id); } @@ -315,12 +332,12 @@ class TreeHtmlRenderer } else { // Inaktive Accounts $paymentDate = $item->payment_account_date ?: ''; - $html .= '
    ' . __('team.account_to') . ': ' . e($paymentDate) . ''; + $html .= '
    '.__('team.account_to').': '.e($paymentDate).''; } // Sponsor für parentlose User if ($showSponsor && $item->m_sponsor_name) { - $html .= '
    ' . e($item->m_sponsor_name); + $html .= '
    '.e($item->m_sponsor_name); } $html .= '
    '; @@ -341,12 +358,12 @@ class TreeHtmlRenderer $eSum = $item->sales_volume_total ?: 0; $sSum = $item->sales_volume_total_shop ?: 0; - return '' . __('team.total_points') . ': ' . formatNumber($totalPoints) . ' | ' . - __('team.e') . ': ' . formatNumber($ePoints) . ' | ' . - __('team.s') . ': ' . formatNumber($sPoints) . ' | ' . - __('team.net_turnover') . ': ' . formatNumber($totalSum) . ' € | ' . - __('team.e') . ': ' . formatNumber($eSum) . ' € | ' . - __('team.s') . ': ' . formatNumber($sSum) . ' €'; + return ''.__('team.total_points').': '.formatNumber($totalPoints).' | '. + __('team.e').': '.formatNumber($ePoints).' | '. + __('team.s').': '.formatNumber($sPoints).' | '. + __('team.net_turnover').': '.formatNumber($totalSum).' € | '. + __('team.e').': '.formatNumber($eSum).' € | '. + __('team.s').': '.formatNumber($sSum).' €'; } /** @@ -354,15 +371,15 @@ class TreeHtmlRenderer */ private function renderActionButton(int $userId): string { - return ' | '; } @@ -380,12 +397,100 @@ class TreeHtmlRenderer } } + /** + * Lädt die Abo-Kennzeichen für alle übergebenen User-IDs gebatcht + * (zwei Group-by-Queries statt Einzelabfragen pro Knoten). + * + * Zählweise konsistent zur Backoffice-Statistik: aktive Abos = active=true + * und status NOT IN (4,5,6) via {@see UserAbo::scopeActiveAbo()}. + * + * @param array $userIds + */ + public function loadAboBadges(array $userIds): void + { + $userIds = array_values(array_unique(array_filter($userIds))); + + if (empty($userIds)) { + return; + } + + $ownAboUserIds = UserAbo::query() + ->activeAbo() + ->where('is_for', 'me') + ->whereIn('user_id', $userIds) + ->distinct() + ->pluck('user_id'); + + foreach ($ownAboUserIds as $userId) { + $this->aboBadges[(int) $userId]['own'] = true; + } + + $customerAboCounts = UserAbo::query() + ->activeAbo() + ->where('is_for', 'ot') + ->whereIn('member_id', $userIds) + ->groupBy('member_id') + ->selectRaw('member_id, COUNT(*) as abo_count') + ->pluck('abo_count', 'member_id'); + + foreach ($customerAboCounts as $userId => $count) { + $this->aboBadges[(int) $userId]['customer_count'] = (int) $count; + } + } + + /** + * Rendert die Abo-Kennzeichen für einen User: + * grünes "A"-Badge bei eigenem aktiven Abo, rotes "(n)"-Badge bei n > 0 eigenen aktiven Kundenabos. + */ + public function renderAboBadges(int $userId): string + { + $hasOwnAbo = $this->aboBadges[$userId]['own'] ?? false; + $customerAboCount = $this->aboBadges[$userId]['customer_count'] ?? 0; + + $html = ''; + + if ($hasOwnAbo) { + $html .= ' A'; + } + + if ($customerAboCount > 0) { + $html .= ' ('.$customerAboCount.')'; + } + + return $html; + } + + /** + * Sammelt rekursiv alle User-IDs aus den Business-User-Items. + * + * @return array + */ + private function collectUserIds(array $items): array + { + $userIds = []; + + foreach ($items as $item) { + $userId = (int) $item->user_id; + if ($userId > 0) { + $userIds[] = $userId; + } + + if (isset($item->businessUserItems) && is_array($item->businessUserItems) && count($item->businessUserItems) > 0) { + $userIds = array_merge($userIds, $this->collectUserIds($item->businessUserItems)); + } + } + + return $userIds; + } + /** * Setzt den Kontext für die Darstellung */ public function setInitFrom(string $initFrom): self { $this->initFrom = $initFrom; + return $this; } } diff --git a/app/Services/Credit.php b/app/Services/Credit.php index 40a3e572..99c7bee9 100644 --- a/app/Services/Credit.php +++ b/app/Services/Credit.php @@ -15,14 +15,14 @@ class Credit return (int) Setting::getContentBySlug('invoice-number'); } + /** + * Vergibt atomar die nächste Gutschriftsnummer aus dem gemeinsamen + * Nummernkreis (Setting 'invoice-number') — gleicher Lock-Mechanismus wie + * bei Rechnungen, damit parallele Läufe keine doppelten Nummern erzeugen. + */ public static function makeNextCreditNumber() { - $credit_number = self::getCreditNumber(); - $credit_number = $credit_number + 1; - // Setting::setContentBySlug('credit-number', $credit_number, 'int'); - Setting::setContentBySlug('invoice-number', $credit_number, 'int'); - - return $credit_number; + return Invoice::makeNextInvoiceNumber(); } public static function createCreditNumber($credit_number, $credit_date) diff --git a/app/Services/DatevExportService.php b/app/Services/DatevExportService.php index c6db121b..73f737df 100644 --- a/app/Services/DatevExportService.php +++ b/app/Services/DatevExportService.php @@ -282,13 +282,13 @@ class DatevExportService $isEu = $this->isEuCountry($country); $euUstid = $this->resolveEuUstid($order, $isDomestic); - $hasValidUstid = ! empty($euUstid); if (! $isDomestic && $country) { $euLand = $country->code ?? null; } - // Tax-Split vorhanden? -> Mehrere Zeilen pro Steuersatz + // Tax-Split vorhanden? -> Bestellung wurde MIT USt berechnet: + // Mehrere Zeilen pro Steuersatz, nie steuerfreie igL. if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) { $netSplit = $this->resolveNetSplit($order); @@ -307,7 +307,7 @@ class DatevExportService continue; } - $revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid); + $revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, false); $lines->push([ 'source_type' => DatevExportLine::SOURCE_INVOICE, @@ -327,15 +327,18 @@ class DatevExportService ]); } } else { - // Kein Tax-Split: Gesamtbetrag als eine Zeile + // Kein Tax-Split -> Bestellung wurde OHNE USt verkauft (igL/Drittland) + // oder Inland-Altfall. EU-Lieferungen ohne USt sind steuerfreie igL, + // unabhängig davon, ob die USt-ID heute noch am Account hinterlegt ist. $grossAmount = floatval($order->total_shipping ?? $order->total ?? 0); if ($grossAmount <= 0) { continue; } + $soldTaxFree = $isEu && ! $isDomestic; $taxRate = intval($order->tax_rate ?? 19); - $revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid); + $revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $soldTaxFree); $lines->push([ 'source_type' => DatevExportLine::SOURCE_INVOICE, @@ -390,8 +393,8 @@ class DatevExportService $user = $credit->user; $account = $user ? $user->account : null; - // Steuerstatus des Beraters ermitteln - $taxStatus = $this->determineCommissionTaxStatus($account); + // Steuerstatus aus dem Snapshot der Gutschrift (nicht aktueller Account-Stand) + $taxStatus = $this->determineCommissionTaxStatus($account, $credit); $buSchluessel = config('datev.commission_tax_keys.'.$taxStatus, 9); // USt-ID für Reverse Charge @@ -424,10 +427,10 @@ class DatevExportService $konto = $commissionAccounts[$type] ?? $commissionAccounts['shop']; // Bei USt-pflichtigen Beratern: Brutto berechnen + // (Steuersatz aus dem Snapshot der Gutschrift, wie auf dem Beleg ausgewiesen) $grossAmount = $totalAmount; - if ($taxStatus === 'normal' && $credit->taxable == 1) { - // Netto + USt = Brutto - $taxRate = config('app.main_tax_rate', 19); + if ($taxStatus === 'normal') { + $taxRate = floatval($credit->tax_rate) ?: config('app.main_tax_rate', 19); $grossAmount = round($totalAmount * (1 + $taxRate / 100), 2); } @@ -513,7 +516,6 @@ class DatevExportService $isEu = $this->isEuCountry($country); $euLand = (! $isDomestic && $country) ? ($country->code ?? null) : null; $euUstid = $this->resolveEuUstid($order, $isDomestic); - $hasValidUstid = ! empty($euUstid); if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) { $netSplit = $this->resolveNetSplit($order); @@ -532,7 +534,7 @@ class DatevExportService continue; } - $revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid); + $revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, false); // Storno: Negativer Betrag $lines->push([ @@ -558,8 +560,9 @@ class DatevExportService continue; } + $soldTaxFree = $isEu && ! $isDomestic; $taxRate = intval($order->tax_rate ?? 19); - $revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid); + $revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $soldTaxFree); $lines->push([ 'source_type' => DatevExportLine::SOURCE_CANCELLATION, @@ -831,12 +834,15 @@ class DatevExportService */ /** - * Bestimmt das Erlöskonto und den BU-Schlüssel basierend auf Steuersatz, Land und USt-ID. + * Bestimmt das Erlöskonto und den BU-Schlüssel basierend auf Steuersatz, Land + * und der tatsächlichen Besteuerung der Bestellung. * - * EU-steuerfreie IG-Lieferung (BU 1 / Konto 8125) nur wenn eine gültige USt-ID vorliegt. - * Ohne USt-ID wird auch bei EU-Lieferungen die reguläre Inlandsbesteuerung angewendet. + * Maßgeblich ist, wie die Rechnung ausgestellt wurde ($soldTaxFree = ohne USt + * verkauft), nicht der aktuelle Account-Zustand: EU-Bestellungen ohne USt sind + * steuerfreie IG-Lieferungen (BU 1 / Konto 8125); mit USt berechnete + * EU-Bestellungen werden wie Inland gebucht. */ - private function determineRevenueAccount(int $taxRate, bool $isDomestic, bool $isEu, bool $hasValidUstid = false): array + private function determineRevenueAccount(int $taxRate, bool $isDomestic, bool $isEu, bool $soldTaxFree = false): array { $accounts = config('datev.revenue_accounts'); @@ -844,7 +850,7 @@ class DatevExportService return $accounts['third_country_exempt']; } - if ($isEu && ! $isDomestic && $hasValidUstid) { + if ($isEu && ! $isDomestic && $soldTaxFree) { return $accounts['eu_exempt']; } @@ -884,33 +890,55 @@ class DatevExportService } /** - * Bestimmt den Steuerstatus eines Beraters für die Provision. + * Bestimmt den Steuerstatus einer Provisions-Gutschrift. + * + * Maßgeblich ist der taxable-Snapshot der Gutschrift (Zustand zum + * Ausstellungszeitpunkt, wie auf dem Beleg ausgewiesen), NICHT der aktuelle + * Account-Zustand. Nur Alt-Gutschriften ohne Snapshot (taxable=0) fallen + * auf die Account-Logik zurück. */ - private function determineCommissionTaxStatus($account): string + private function determineCommissionTaxStatus($account, $credit = null): string { + $taxable = intval($credit->taxable ?? 0); + + if ($taxable === 1) { + return 'normal'; + } + + if ($taxable === 2) { + return 'kleinunternehmer'; + } + + if ($taxable === 3) { + // Ausland: EU -> Reverse Charge, Drittland -> kein BU nötig, KU als Fallback + $country = $account?->country; + if ($country && $country->eu_country) { + return 'reverse_charge'; + } + + return 'kleinunternehmer'; + } + + // Fallback für Alt-Gutschriften ohne taxable-Snapshot if (! $account) { return 'normal'; } - // Reverse Charge hat Vorrang if ($account->reverse_charge) { return 'reverse_charge'; } - // Kleinunternehmer (taxable_sales = 2) if ($account->taxable_sales == 2) { return 'kleinunternehmer'; } - // Ausland nicht USt-pflichtig (taxable_sales = 3) - // Wird wie Reverse Charge behandelt, falls aus EU if ($account->taxable_sales == 3) { $country = $account->country; if ($country && $country->eu_country) { return 'reverse_charge'; } - return 'kleinunternehmer'; // Drittland: kein BU nötig, aber KU als Fallback + return 'kleinunternehmer'; } return 'normal'; @@ -970,8 +998,8 @@ class DatevExportService /** * Ermittelt die USt-ID des Bestellers (auth_user). - * Nur registrierte Berater mit hinterlegter USt-ID qualifizieren sich - * für steuerfreie innergemeinschaftliche Lieferungen. + * Deutsche oder formal unplausible USt-IDs werden verworfen — sie + * qualifizieren nie für steuerfreie innergemeinschaftliche Lieferungen. */ private function resolveEuUstid($order, bool $isDomestic): ?string { @@ -979,7 +1007,13 @@ class DatevExportService return null; } - return $order->auth_user?->account?->tax_identification_number ?: null; + $ustid = $order->auth_user?->account?->tax_identification_number ?: null; + + if ($ustid && ! app(VatIdService::class)->qualifiesForReverseCharge($ustid)) { + return null; + } + + return $ustid; } /** @@ -1064,10 +1098,11 @@ class DatevExportService } /** - * Parst einen formatierten Zahlenwert (z.B. "5.00" oder "5,00") zu float. + * Parst einen formatierten Zahlenwert (z.B. "5.00", "5,00" oder "1,434.70") zu float. * Behandelt sowohl einfache Werte als auch tax_split Arrays (homeparty). + * Tausendertrennzeichen ("1,434.70" oder "1.434,70") werden korrekt entfernt. */ - private function parseNumber($value, ?string $preferredArrayKey = null): float + public function parseNumber($value, ?string $preferredArrayKey = null): float { if (is_array($value)) { $arrayKeys = $this->getSplitArrayKeys($preferredArrayKey); @@ -1081,7 +1116,38 @@ class DatevExportService return 0.0; } - return floatval(str_replace(',', '.', $value)); + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + $value = trim((string) $value); + if ($value === '') { + return 0.0; + } + + $hasComma = str_contains($value, ','); + $hasDot = str_contains($value, '.'); + + if ($hasComma && $hasDot) { + if (strrpos($value, ',') > strrpos($value, '.')) { + // Deutsches Format: 1.434,70 + $value = str_replace('.', '', $value); + $value = str_replace(',', '.', $value); + } else { + // US-Format: 1,434.70 + $value = str_replace(',', '', $value); + } + } elseif ($hasComma) { + if (preg_match('/^-?\d{1,3}(,\d{3})+$/', $value)) { + // Nur Tausendertrenner: 1,434 + $value = str_replace(',', '', $value); + } else { + // Komma als Dezimaltrenner: 5,00 + $value = str_replace(',', '.', $value); + } + } + + return floatval($value); } /** diff --git a/app/Services/GdprDeletionService.php b/app/Services/GdprDeletionService.php new file mode 100644 index 00000000..885de012 --- /dev/null +++ b/app/Services/GdprDeletionService.php @@ -0,0 +1,353 @@ + Zusammenfassung der ausgeführten Schritte + * + * @throws GdprDeletionException wenn Sperrgründe vorliegen (Admin-Account, aktives Abo) + */ + public function deleteUser(User $user, ?int $executedByUserId = null): array + { + if ($user->admin >= 2) { + throw new GdprDeletionException(__('msg.gdpr_error_is_admin')); + } + + $activeAbos = UserAbo::where('user_id', $user->id)->where('active', true)->count(); + if ($activeAbos > 0) { + throw new GdprDeletionException(__('msg.gdpr_error_active_abo')); + } + + $originalEmail = $user->email; + + $summary = DB::transaction(function () use ($user) { + $summary = ['user_id' => $user->id]; + + // MLM-Struktur erhalten: Downline und Kunden auf den nächsten aktiven Sponsor + // übertragen. Bewusst beim Sponsor starten, nicht beim User selbst — ein noch + // aktiver User würde sonst sich selbst als neuen Sponsor erhalten. + $active_sponsor = UserUtil::findNextActiveSponsor($user->m_sponsor ?: $user->pre_sponsor); + if ($active_sponsor && $active_sponsor->id !== $user->id) { + UserUtil::setNewSponsorToChilds($user->id, $active_sponsor->id); + UserUtil::setShoppingUserToNewMember($user->id, $active_sponsor->id); + $summary['new_sponsor_id'] = $active_sponsor->id; + } + + // Bestehende Komplett-Löschung: E-Mail randomisieren, Zugangsdaten entwerten, + // Account + Shop soft-deleten + UserUtil::deleteUser($user, true); + + $this->anonymizeUserRow($user); + $this->anonymizeUserAccount($user); + $this->anonymizeUserShop($user); + $summary['files_deleted'] = $this->deleteUserFiles($user); + $summary['update_emails_deleted'] = UserUpdateEmail::where('user_id', $user->id)->delete(); + $summary['abos_anonymized'] = $this->anonymizeUserAbos('user_id', $user->id); + $summary['business_rows_anonymized'] = $this->anonymizeUserBusinessRows($user); + $summary['shopping_users_anonymized'] = $this->anonymizeShoppingUserRows( + ShoppingUser::withTrashed()->where('auth_user_id', $user->id)->get() + ); + + return $summary; + }); + + $this->logDeletion('gdpr-delete-user', $user->id, User::class, $executedByUserId, $originalEmail, $summary); + + return $summary; + } + + /** + * Endkunden (ShoppingUser) DSGVO-konform löschen. + * + * Anonymisiert alle Datensätze des Kunden (Einträge mit gleicher Kundennummer). + * + * @return array + * + * @throws GdprDeletionException wenn der Datensatz zu einem Berater gehört oder ein aktives Abo besteht + */ + public function deleteShoppingUser(ShoppingUser $shoppingUser, ?int $executedByUserId = null): array + { + if ($shoppingUser->auth_user_id) { + throw new GdprDeletionException(__('msg.gdpr_error_customer_is_member')); + } + + $rows = $this->getCustomerRows($shoppingUser); + + $activeAbos = UserAbo::whereIn('shopping_user_id', $rows->pluck('id'))->where('active', true)->count(); + if ($activeAbos > 0) { + throw new GdprDeletionException(__('msg.gdpr_error_active_abo')); + } + + $originalEmail = $shoppingUser->billing_email; + + $summary = DB::transaction(function () use ($shoppingUser, $rows) { + $summary = ['shopping_user_id' => $shoppingUser->id]; + $summary['abos_anonymized'] = 0; + foreach ($rows as $row) { + $summary['abos_anonymized'] += $this->anonymizeUserAbos('shopping_user_id', $row->id); + } + $summary['shopping_users_anonymized'] = $this->anonymizeShoppingUserRows($rows); + + return $summary; + }); + + $this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary); + + return $summary; + } + + /** + * Alle Datensätze eines Endkunden (gleiche Kundennummer), inkl. soft-deleted. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getCustomerRows(ShoppingUser $shoppingUser) + { + if ($shoppingUser->number > 0) { + return ShoppingUser::withTrashed()->where('number', $shoppingUser->number)->get(); + } + + return ShoppingUser::withTrashed()->where('id', $shoppingUser->id)->get(); + } + + protected function anonymizeUserRow(User $user): void + { + $user->notes = null; + $user->settings = null; + $user->payment_methods = null; + $user->last_login = null; + $user->save(); + } + + protected function anonymizeUserAccount(User $user): void + { + if (! $user->account_id) { + return; + } + $account = UserAccount::withTrashed()->find($user->account_id); + if (! $account) { + return; + } + + $account->m_salutation = null; + $account->m_first_name = self::ANONYMIZED_FIRSTNAME; + $account->m_last_name = self::ANONYMIZED_LASTNAME; + $account->m_notes = null; + $account->company = null; + $account->salutation = null; + $account->first_name = self::ANONYMIZED_FIRSTNAME; + $account->last_name = self::ANONYMIZED_LASTNAME; + $account->address = null; + $account->address_2 = null; + $account->zipcode = null; + $account->city = null; + $account->pre_phone_id = null; + $account->phone = null; + $account->pre_mobil_id = null; + $account->mobil = null; + $account->tax_number = null; + $account->tax_identification_number = null; + $account->shipping_salutation = null; + $account->shipping_company = null; + $account->shipping_firstname = null; + $account->shipping_lastname = null; + $account->shipping_address = null; + $account->shipping_address_2 = null; + $account->shipping_zipcode = null; + $account->shipping_city = null; + $account->shipping_pre_phone_id = null; + $account->shipping_phone = null; + $account->shipping_postnumber = null; + $account->birthday = null; + $account->website = null; + $account->facebook = null; + $account->facebook_fanpage = null; + $account->instagram = null; + $account->bank_owner = null; + $account->bank_iban = null; + $account->bank_bic = null; + $account->payment_data = null; + $account->notice = null; + $account->save(); + } + + protected function anonymizeUserShop(User $user): void + { + $shop = UserShop::withTrashed()->where('user_id', $user->id)->first(); + if (! $shop) { + return; + } + + if ($shop->filename) { + Storage::disk('public')->delete('images/shop/'.$shop->filename); + } + $shop->title = null; + $shop->contact = null; + $shop->accessibility = null; + $shop->about = null; + $shop->trans = null; + $shop->filename = null; + $shop->originalname = null; + $shop->ext = null; + $shop->mine = null; + $shop->size = null; + $shop->save(); + } + + protected function deleteUserFiles(User $user): int + { + $count = 0; + foreach ($user->files()->get() as $file) { + Storage::disk('user')->delete($file->dir.$file->filename); + $file->delete(); + $count++; + } + + return $count; + } + + /** + * Zahlungs-Tokens und Kontaktdaten aller Abos entfernen (auch soft-deleted). + */ + protected function anonymizeUserAbos(string $column, int $id): int + { + $count = 0; + $abos = UserAbo::withTrashed()->where($column, $id)->get(); + foreach ($abos as $abo) { + $abo->email = null; + $abo->carddata = null; + $abo->payone_userid = 0; + $abo->stripe_customer_id = null; + $abo->stripe_payment_method_id = null; + $abo->active = false; + $abo->save(); + $count++; + } + + return $count; + } + + /** + * Denormalisierte Personendaten in den monatlichen Provisions-Snapshots + * anonymisieren; die Finanzdaten (Umsätze, Provisionen) bleiben erhalten. + */ + protected function anonymizeUserBusinessRows(User $user): int + { + return UserBusiness::where('user_id', $user->id)->update([ + 'email' => null, + 'first_name' => self::ANONYMIZED_FIRSTNAME, + 'last_name' => self::ANONYMIZED_LASTNAME, + 'user_birthday' => null, + 'user_phone' => null, + ]); + } + + /** + * Kunden-Datensätze anonymisieren. Datensätze mit Bestellungen behalten + * Name und Rechnungsadresse (Aufbewahrungspflicht Buchungsbeleg), + * verlieren aber alle Kontaktdaten. Datensätze ohne Bestellung werden + * vollständig anonymisiert. + * + * @param Collection $rows + */ + protected function anonymizeShoppingUserRows($rows): int + { + $count = 0; + foreach ($rows as $row) { + $hasOrders = $row->shopping_orders()->withTrashed()->count() > 0; + if ($hasOrders) { + $this->anonymizeShoppingUserContactData($row); + } else { + $this->anonymizeShoppingUserComplete($row); + } + $count++; + } + + return $count; + } + + protected function anonymizeShoppingUserContactData(ShoppingUser $row): void + { + $row->billing_phone = null; + $row->billing_email = 'geloescht_'.$row->id.'@dsgvo.invalid'; + $row->shipping_phone = null; + $row->shipping_email = null; + $row->remarks = ''; + $row->notice = []; + $row->subscribed = false; + $row->faker_mail = true; + $row->save(); + } + + protected function anonymizeShoppingUserComplete(ShoppingUser $row): void + { + $row->billing_salutation = null; + $row->billing_company = null; + $row->billing_firstname = self::ANONYMIZED_FIRSTNAME; + $row->billing_lastname = self::ANONYMIZED_LASTNAME; + $row->billing_address = null; + $row->billing_address_2 = null; + $row->billing_zipcode = null; + $row->billing_city = null; + $row->shipping_salutation = null; + $row->shipping_company = null; + $row->shipping_firstname = null; + $row->shipping_lastname = null; + $row->shipping_address = null; + $row->shipping_address_2 = null; + $row->shipping_zipcode = null; + $row->shipping_city = null; + $row->shipping_postnumber = null; + $this->anonymizeShoppingUserContactData($row); + } + + /** + * Nachweis der Löschung (Art. 5 Abs. 2 DSGVO) ohne personenbezogene Daten. + * + * @param array $summary + */ + protected function logDeletion(string $action, int $modelId, string $modelClass, ?int $executedByUserId, ?string $originalEmail, array $summary): void + { + SysLog::action($action, 'gdpr', 4) + ->setUserId($executedByUserId) + ->setModel($modelId, $modelClass) + ->setMessage('DSGVO-Löschung ausgeführt. Umfang: '.json_encode($summary)) + ->save(); + + Log::channel('cleanup')->info($action.' id:'.$modelId.' email-hash:'.hash('sha256', (string) $originalEmail).' summary:'.json_encode($summary)); + } +} diff --git a/app/Services/Incentive/IncentiveTracker.php b/app/Services/Incentive/IncentiveTracker.php index 1be9c96d..803e067c 100644 --- a/app/Services/Incentive/IncentiveTracker.php +++ b/app/Services/Incentive/IncentiveTracker.php @@ -316,12 +316,15 @@ class IncentiveTracker /** * Update ranking for all participants of an incentive. + * Der Rang entspricht der Anzeigereihenfolge des Leaderboards: Qualifizierte zuerst (1..n), + * dahinter Nicht-Qualifizierte, jeweils nach Punkten absteigend. */ public static function updateRanking(Incentive $incentive): void { // Nur Teilnehmer mit Punkten bekommen einen Rang (bei Punktgleichstand: Teilnahme bestaetigt vor anonym) $with_points = IncentiveParticipant::where('incentive_id', $incentive->id) ->where('total_points', '>', 0) + ->orderByDesc('is_qualified') ->orderByDesc('total_points') ->orderByRaw('accepted_terms_at IS NOT NULL DESC') ->get(); diff --git a/app/Services/SyS/DomainSSL.php b/app/Services/SyS/DomainSSL.php deleted file mode 100644 index d45508b5..00000000 --- a/app/Services/SyS/DomainSSL.php +++ /dev/null @@ -1,198 +0,0 @@ -get(); - $text = ""; - - $kas = new KasController(); - //$domain = 'mivita.care'; - // $ssl = KasSLLController::getApiSSLParameter(); - $SubDomains = []; - - $get_subdomains = $kas->action('get_subdomains'); - foreach ($get_subdomains as $subdomain){ - if(isset($subdomain['subdomain_name'])){ - if(strpos($subdomain['subdomain_name'], 'www.') !== false){ - continue; - } - if(strpos($subdomain['subdomain_name'], 'api.') !== false){ - continue; - } - if(strpos($subdomain['subdomain_name'], 'checkout.') !== false){ - continue; - } - $SubDomains[$subdomain['subdomain_name']] = - [ - 'ssl_certificate_sni' => $subdomain['ssl_certificate_sni'], - 'php_version' => $subdomain['php_version'], - ]; - - }else{ - @dump($subdomain); - } - - - /* if($subdomain['ssl_certificate_sni'] !== "Y"){ - $pra = array( - 'hostname' => $subdomain['subdomain_name'], - ); - $pra = array_merge($pra, $ssl); - $value = $kas->action('update_ssl', $pra); - $text .= $value; - }else{ - if(isset($subdomain['ssl_certificate_sni_is_active'])){ - $text .= $subdomain['ssl_certificate_sni_is_active'].'-is_active'; - }else{ - $text .= '-CHECK!'; - - } - }*/ - - //$text .= "\n"; - } - - $dobbleDomains = []; - - foreach($user_shops as $user_shop){ - $user_shop->hasSubdomain = false; - $user_shop->hasSSL = false; - $user_shop->PHPversion = ""; - if(array_key_exists($user_shop->slug.'.mivita.care', $SubDomains)){ - $user_shop->hasSubdomain = true; - $user_shop->hasSSL = $SubDomains[$user_shop->slug.'.mivita.care']['ssl_certificate_sni'] === 'Y' ? true : false; - $user_shop->PHPversion = $SubDomains[$user_shop->slug.'.mivita.care']['php_version']; - unset($SubDomains[$user_shop->slug.'.mivita.care']); - } - $dobbleDomains[$user_shop->user_id][$user_shop->id] = $user_shop->slug.'.mivita.care'; - } - foreach($dobbleDomains as $key => $dobbleDomain){ - if(count($dobbleDomain) === 1){ - unset($dobbleDomains[$key]); - } - } - $data = [ - 'values' => $user_shops, - 'text' => $text, - 'SubDomains' => $SubDomains, - 'dobbleDomains' => $dobbleDomains, - - ]; - - return view('sys.tools.domain-ssl', $data); - } - - - public static function store() - { - $data = Request::all(); - if(isset($data['delete_sub_kas'])){ - $kas = new KasController(); - $pra = array( - 'subdomain_name' => $data['delete_sub_kas'], - ); - $value = $kas->action('delete_subdomain', $pra); - \Session()->flash('alert-success', 'subdomain: '.$value.' gelöscht'); - - } - if(isset($data['delete_user_shop'])){ - $user_shop = UserShop::findOrFail($data['delete_user_shop']); - $subdomain_name = $user_shop->slug.'.mivita.care'; - $user_shop->name = "delete".$user_shop->id; - $user_shop->slug = "delete".$user_shop->id; - $user_shop->save(); - $user_shop->delete(); - $kas = new KasController(); - $pra = array( - 'subdomain_name' => $subdomain_name, - ); - $value = $kas->action('delete_subdomain', $pra); - \Session()->flash('alert-success', 'shop/subdomain: '.$value.' gelöscht'); - - } - - if(isset($data['update_ssl'])){ - - //prüfen ob subdomain existiert - $user_shop = UserShop::findOrFail($data['update_ssl']); - $subdomain_name = $user_shop->slug.'.mivita.care'; - - $kas = new KasController(); - $subdomain = $kas->action('get_subdomains', ['subdomain_name' => $subdomain_name]); - if(!isset($subdomain[0]['subdomain_name'])){ //not found - self::makeNewSubDomain($user_shop->slug); - \Session()->flash('alert-danger', 'subdomain: '.$subdomain_name.' nicht gefunden'); - return back(); - }else{ - self::updateSubDomain($subdomain_name); - } - - - - } - - - return back(); - } - - private static function updateSubDomain($subdomain_name){ - - $kas = new KasController(); - $ssl = KasSLLController::getApiSSLParameter(); - $pra = array( - 'hostname' => $subdomain_name - ); - $pra = array_merge($pra, $ssl); - $value = $kas->action('update_ssl', $pra); - \Session()->flash('alert-success', 'update SSL: '.$value.''); - return $value; - } - - private static function makeNewSubDomain($slug){ - - $kas = new KasController(); - $domain = 'mivita.care'; - - //check if exisist - $subdomains = $kas->action('get_subdomains'); - foreach ($subdomains as $subdomain){ - if(!isset($subdomain['subdomain_name'])){ - continue; - } - $sub = str_replace(".".$domain, '', $subdomain['subdomain_name']); - if($sub == $slug){ - \Session()->flash('alert-danger', __('shop.error_subdomain_exists')); - return; - } - } - - //add - $full_subdomain_name = $slug.".".$domain; - $pra = array( - 'subdomain_name' => $slug, - 'domain_name' => $domain, - 'subdomain_path' => '/mein.mivita.care/public/', - 'php_version' => config('app.php_version'), - //'ssl_proxy' => 'Y', - //'redirect_status' => 0 - ); - $add_subdomain = $kas->action('add_subdomain', $pra); - if($add_subdomain == $full_subdomain_name){ - \Session()->flash('alert-success', 'Add Subdomain: '.$add_subdomain.''); - return $add_subdomain; - } - \Session()->flash('alert-danger', 'Fehler: '.$add_subdomain.''); - return $add_subdomain; - - - } -} diff --git a/app/Services/SyS/ShoppingOrders.php b/app/Services/SyS/ShoppingOrders.php index dd1ee65f..754e1649 100644 --- a/app/Services/SyS/ShoppingOrders.php +++ b/app/Services/SyS/ShoppingOrders.php @@ -1,17 +1,13 @@ $shopping_users, 'text' => '', ]; + return view('sys.tools.shopping-orders', $data); } - public static function store() { abort(403, 'STOP funtion not online'); - dd(""); - + dd(''); $data = Request::all(); - - if($data['action'] === 'first_run'){ - $shopping_users = ShoppingUser::whereHas('shopping_order', function($q) { + + if ($data['action'] === 'first_run') { + $shopping_users = ShoppingUser::whereHas('shopping_order', function ($q) { $q->where('txaction', 'paid')->OrWhere('txaction', 'appointed'); })->get(); $order_email = []; $order_number = []; - foreach ($shopping_users as $shopping_user){ + foreach ($shopping_users as $shopping_user) { $order_email[$shopping_user->billing_email] = isset($order_email[$shopping_user->billing_email]) ? $order_email[$shopping_user->billing_email] + 1 : 1; - if($shopping_user->number) { + if ($shopping_user->number) { $order_number[$shopping_user->number] = isset($order_number[$shopping_user->number]) ? $order_number[$shopping_user->number] + 1 : 1; $shopping_user->orders = $order_number[$shopping_user->number]; - }else { + } else { $shopping_user->orders = $order_email[$shopping_user->billing_email]; } $shopping_user->save(); @@ -55,11 +50,11 @@ class ShoppingOrders \Session()->flash('alert-save', true); } - if($data['action'] === 'next_run'){ + if ($data['action'] === 'next_run') { ShoppingUserService::snycOrdersByShoppingUser($shopping_user); \Session()->flash('alert-save', true); } + return back(); } - } diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 753e7335..19d8f90b 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -121,6 +121,10 @@ class UserService if ($user->account->reverse_charge && $user->account->reverse_charge_valid) { // Rechnungsland ist auch Lieferland, dann RSV if (strtolower($user->account->reverse_charge_code) == strtolower($ShippingCountry->country->code)) { + // USt-ID bei jeder Verwendung erneut prüfen (VIES, gecacht) + if (! self::verifyReverseChargeVatId($user)) { + return false; + } self::$user_reverse_charge = true; return true; @@ -132,6 +136,49 @@ class UserService return false; } + /** + * Prüft die hinterlegte USt-ID bei jeder Verwendung erneut. + * Deutsche, formal ungültige oder laut VIES ungültige USt-IDs verlieren den + * Reverse-Charge-Status; bei VIES-Ausfall gilt der gespeicherte Status weiter. + */ + public static function verifyReverseChargeVatId(User $user): bool + { + $vatService = app(VatIdService::class); + $vatId = $user->account->tax_identification_number; + + if (! $vatService->qualifiesForReverseCharge($vatId)) { + self::revokeReverseCharge($user); + + return false; + } + + $result = $vatService->checkViesCached($vatId, $user->account->country->code ?? null); + + if ($result === VatIdService::CHECK_INVALID) { + self::revokeReverseCharge($user); + + return false; + } + + // valid oder VIES nicht erreichbar (Fallback auf gespeicherten Status) + return true; + } + + /** + * Entzieht den Reverse-Charge-Status und informiert den User im Checkout, + * dass die Bestellung mit Umsatzsteuer berechnet wird. + */ + private static function revokeReverseCharge(User $user): void + { + $user->account->reverse_charge = 0; + $user->account->reverse_charge_valid = null; + $user->account->save(); + + if (app()->bound('request') && request()->hasSession()) { + request()->session()->flash('alert-error', __('msg.VATID_no_longer_valid_taxes_applied')); + } + } + public static function getYardInfo() { return [ diff --git a/app/Services/UserUtil.php b/app/Services/UserUtil.php index 5e70c72a..483b979d 100644 --- a/app/Services/UserUtil.php +++ b/app/Services/UserUtil.php @@ -2,7 +2,6 @@ namespace App\Services; -use App\Http\Controllers\Api\KasController; use App\Models\ShoppingUser; use App\Models\ShoppingUserMemberLog; use App\Models\UserCleanUpLog; @@ -171,19 +170,10 @@ class UserUtil { // shop wird gelöscht if ($user->shop) { - // $subdomain_name = $user->shop->slug . '.mivita.care'; $user->shop->name = 'delete'.$user->shop->id; $user->shop->slug = 'delete'.$user->shop->id; $user->shop->save(); $user->shop->delete(); - // isset KAS - delete Subdomain - /*if (!Util::isTestSystem()) { - $kas = new KasController(); - $pra = array( - 'subdomain_name' => $subdomain_name, - ); - $kas->action('delete_subdomain', $pra); - }*/ } // user soll nicht komplett gelöscht werden diff --git a/app/Services/VatIdService.php b/app/Services/VatIdService.php new file mode 100644 index 00000000..a165a54a --- /dev/null +++ b/app/Services/VatIdService.php @@ -0,0 +1,173 @@ + + */ + private const EU_COUNTRY_CODES = [ + 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'HR', 'HU', + 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'XI', + ]; + + /** + * Entfernt Leerzeichen, Punkte, Kommas und Bindestriche und wandelt in Großbuchstaben um. + */ + public function normalize(?string $vatId): string + { + return strtoupper(str_replace([' ', '.', '-', ','], '', trim((string) $vatId))); + } + + /** + * Liefert den Ländercode aus dem USt-ID-Präfix, sonst den Fallback (Account-Land). + */ + public function extractCountryCode(?string $vatId, ?string $fallbackCountryCode = null): ?string + { + $normalized = $this->normalize($vatId); + $prefix = substr($normalized, 0, 2); + + if (in_array($prefix, self::EU_COUNTRY_CODES, true)) { + return $prefix; + } + + $fallback = strtoupper((string) $fallbackCountryCode); + + return in_array($fallback, self::EU_COUNTRY_CODES, true) ? $fallback : null; + } + + /** + * Liefert den Nummernteil ohne Ländercode-Präfix. + */ + public function extractVatNumber(?string $vatId): string + { + $normalized = $this->normalize($vatId); + + if (in_array(substr($normalized, 0, 2), self::EU_COUNTRY_CODES, true)) { + return substr($normalized, 2); + } + + return $normalized; + } + + /** + * Grobe Formatprüfung: EU-Länderpräfix + 2-13 Zeichen Nummernteil. + */ + public function isPlausibleFormat(?string $vatId): bool + { + $normalized = $this->normalize($vatId); + + return (bool) preg_match('/^[A-Z]{2}[A-Z0-9+*]{2,13}$/', $normalized) + && in_array(substr($normalized, 0, 2), self::EU_COUNTRY_CODES, true); + } + + /** + * Nur ausländische EU-USt-IDs qualifizieren für steuerfreie igL / Reverse-Charge. + * Deutsche USt-IDs (DE...) werden grundsätzlich abgelehnt. + */ + public function qualifiesForReverseCharge(?string $vatId): bool + { + return $this->isPlausibleFormat($vatId) && ! $this->isGermanVatId($vatId); + } + + public function isGermanVatId(?string $vatId): bool + { + return str_starts_with($this->normalize($vatId), 'DE'); + } + + /** + * Live-Prüfung gegen VIES. + * + * @return string self::CHECK_VALID | self::CHECK_INVALID | self::CHECK_UNAVAILABLE + */ + public function checkVies(?string $vatId, ?string $fallbackCountryCode = null): string + { + if (! $this->isPlausibleFormat($vatId)) { + return self::CHECK_INVALID; + } + + $countryCode = $this->extractCountryCode($vatId, $fallbackCountryCode); + $vatNumber = $this->extractVatNumber($vatId); + + if (! $countryCode || $vatNumber === '') { + return self::CHECK_INVALID; + } + + if (! config('services.vies.enabled', true)) { + return self::CHECK_UNAVAILABLE; + } + + $timeout = (int) config('services.vies.timeout', 10); + + try { + $client = new \SoapClient(self::VIES_WSDL, [ + 'cache_wsdl' => WSDL_CACHE_NONE, + 'connection_timeout' => $timeout, + 'exceptions' => true, + 'stream_context' => stream_context_create([ + 'http' => ['timeout' => $timeout], + ]), + ]); + + $result = $client->checkVat([ + 'countryCode' => $countryCode, + 'vatNumber' => $vatNumber, + ]); + + return $result->valid ? self::CHECK_VALID : self::CHECK_INVALID; + } catch (\Throwable $e) { + Log::warning('VIES-Prüfung nicht erreichbar', [ + 'vat_id' => $this->normalize($vatId), + 'error' => $e->getMessage(), + ]); + + return self::CHECK_UNAVAILABLE; + } + } + + /** + * VIES-Prüfung mit Cache (24h). CHECK_UNAVAILABLE wird nur kurz gecacht (10 Min.), + * damit ein VIES-Ausfall den Checkout nicht bei jedem Aufruf blockiert. + */ + public function checkViesCached(?string $vatId, ?string $fallbackCountryCode = null): string + { + if (! $this->isPlausibleFormat($vatId)) { + return self::CHECK_INVALID; + } + + $cacheKey = 'vies-check:'.$this->normalize($vatId); + + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + + $result = $this->checkVies($vatId, $fallbackCountryCode); + + $ttl = $result === self::CHECK_UNAVAILABLE ? now()->addMinutes(10) : now()->addHours(24); + Cache::put($cacheKey, $result, $ttl); + + return $result; + } +} diff --git a/app/Services/Yard.php b/app/Services/Yard.php index ad578ccb..0d91dab7 100644 --- a/app/Services/Yard.php +++ b/app/Services/Yard.php @@ -320,7 +320,7 @@ class Yard extends Cart $this->shipping_price = $price; $this->shipping_tax_rate = $shipping_price->tax_rate; $this->shipping_price_net = round($price / ((100 + $shipping_price->tax_rate) / 100), 2); - $this->shipping_tax = round($price / (100 + $shipping_price->tax_rate) * 100, 2); + $this->shipping_tax = round($price - $this->shipping_price_net, 2); $this->putYardExtra('num_comp', $this->num_comp); $this->putYardExtra('shipping_price', $this->shipping_price); diff --git a/config/services.php b/config/services.php index d5219f2d..24fed224 100755 --- a/config/services.php +++ b/config/services.php @@ -1,5 +1,7 @@ [ - 'model' => App\User::class, + 'model' => User::class, 'key' => env('STRIPE_KEY'), 'secret' => env('STRIPE_SECRET'), ], + 'vies' => [ + 'enabled' => env('VIES_ENABLED', true), + 'timeout' => env('VIES_TIMEOUT', 10), + ], + 'openai' => [ 'api_key' => env('OPENAI_API_KEY'), 'url' => env('OPENAI_API_URL', 'https://api.openai.com/v1/chat/completions'), diff --git a/database/migrations/2026_07_22_160651_add_pause_fields_to_user_abos_table.php b/database/migrations/2026_07_22_160651_add_pause_fields_to_user_abos_table.php new file mode 100644 index 00000000..27bb0bbb --- /dev/null +++ b/database/migrations/2026_07_22_160651_add_pause_fields_to_user_abos_table.php @@ -0,0 +1,29 @@ +date('last_pause_date')->nullable()->after('cancel_date'); + $table->unsignedInteger('pause_count')->default(0)->after('cancel_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_abos', function (Blueprint $table) { + $table->dropColumn(['last_pause_date', 'pause_count']); + }); + } +}; diff --git a/database/migrations/2026_07_22_160652_create_user_abo_status_histories_table.php b/database/migrations/2026_07_22_160652_create_user_abo_status_histories_table.php new file mode 100644 index 00000000..acafef5b --- /dev/null +++ b/database/migrations/2026_07_22_160652_create_user_abo_status_histories_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->unsignedInteger('user_abo_id'); + $table->string('action', 32); + $table->string('view', 16)->nullable(); + $table->unsignedInteger('actor_user_id')->nullable(); + $table->date('previous_next_date')->nullable(); + $table->date('new_next_date')->nullable(); + $table->timestamps(); + + $table->index(['user_abo_id', 'created_at']); + $table->foreign('user_abo_id')->references('id')->on('user_abos')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_abo_status_histories'); + } +}; diff --git a/database/migrations/2026_07_24_120000_deactivate_soft_deleted_user_abos.php b/database/migrations/2026_07_24_120000_deactivate_soft_deleted_user_abos.php new file mode 100644 index 00000000..d9b6c6f1 --- /dev/null +++ b/database/migrations/2026_07_24_120000_deactivate_soft_deleted_user_abos.php @@ -0,0 +1,27 @@ +whereNotNull('deleted_at') + ->where('active', true) + ->update(['active' => false]); + } + + public function down(): void + { + // Nicht umkehrbar: welche gelöschten Abos zuvor active=1 trugen, ist + // nach der Bereinigung nicht mehr feststellbar (bewusster Daten-Fix). + } +}; diff --git a/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-4764.csv b/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-4764.csv new file mode 100644 index 00000000..a166b5e5 --- /dev/null +++ b/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-4764.csv @@ -0,0 +1,3 @@ +Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung +GS202536995;56,7;67,47;-10,77;-10,77;Test;Testdaten fehlerhaft: Betrag;gebuchte Gutschrift stimmt mit Beleg überein +GS202538597;12,5;14,88;-2,38;-2,38;Test;Testdaten fehlerhaft: Betrag;gebuchte Gutschrift stimmt mit Beleg überein \ No newline at end of file diff --git a/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-8125.csv b/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-8125.csv new file mode 100644 index 00000000..dc2cad99 --- /dev/null +++ b/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-8125.csv @@ -0,0 +1,15 @@ +Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung +202536858;#NV;-207,87;207,87;Fehler;Test;Testdaten unvollständig ;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536910;#NV;-131,43;131,43;Fehler;Test;Testdaten unvollständig ;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202537173;#NV;-139,13;139,13;Fehler;Test;Testdaten unvollständig ;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538144;#NV;-131,83;131,83;Fehler;Test;Testdaten unvollständig ;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536841;-111,18;#NV;-111,18;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8400 verbucht nicht 8125 +202538398;-180,09;#NV;-180,09;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8400 verbucht nicht 8125 +202536848;#NV;-277,92;277,92;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein +202537290;#NV;-269,46;269,46;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein +202537430;#NV;-78,18;78,18;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein +202537832;#NV;-153,93;153,93;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein +202538011;#NV;-308,35;308,35;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein +202538445;#NV;-270,6;270,6;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein +202538446;#NV;-427,16;427,16;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein +202539399;#NV;-295,12;295,12;Fehler;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein \ No newline at end of file diff --git a/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-8300.csv b/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-8300.csv new file mode 100644 index 00000000..6ff76ee4 --- /dev/null +++ b/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto-8300.csv @@ -0,0 +1,7 @@ +Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung +202535876;#NV;46,64;-46,64;Fehler;Test;Testdaten unvollständig;Für Rechnung aus Juli wurde eine Gutschrift im September erfasst +202537567;#NV;-75,42;75,42;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538398;#NV;-9,75;9,75;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538182;-64,24;-46,64;-17,6;-17,6;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537572;-75,42;#NV;-75,42;Fehler;Test;Test fehlerhaft: Logik;Rechnung korrekterweise auf 8400 (gebucht), da Stl. Kennung nicht vorhanden, Betrag nicht korrekt +202539011;-61,89;#NV;-61,89;Fehler;;Buchungsdaten unvollständig;Rechnung liegt uns nicht vor \ No newline at end of file diff --git a/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto8400.csv b/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto8400.csv new file mode 100644 index 00000000..bc659713 --- /dev/null +++ b/dev/2026-07-17-steuern/Abgleich 08._09.2025-Konto8400.csv @@ -0,0 +1,37 @@ +Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung +202506145;#NV;-265,8;265,8;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202506147;#NV;-115,5;115,5;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536821;#NV;-444,54;444,54;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536841;#NV;-93,43;93,43;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202537460;#NV;-221,09;221,09;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202537543;#NV;-791,8;791,8;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538398;#NV;-142,57;142,57;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538744;#NV;-87,9;87,9;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536978;-95,29;0;-95,29;-95,29;Test;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Doppeltbestellung +202537144;-146,97;0;-146,97;-146,97;Test;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Falschbestellung +202537513;-101,68;-22,27;-79,41;-79,41;Test;Testdaten fehlerhaft: Storno;Rg. Wurde teilweise storniert +202539501;-93,43;0;-93,43;-93,43;Test;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Generalumkehr +202536858;-174,68;#NV;-174,68;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (207,87€) +202536910;-110,45;#NV;-110,45;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (131,43€) +202537173;-116,92;#NV;-116,92;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (139,13€) +202537430;-65,7;#NV;-65,7;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (78,14€) +202538144;-110,78;#NV;-110,78;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 verbucht nicht auf 8400, Test: Betrag nicht korrekt (131,83€) +202536848;-233,55;#NV;-233,55;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202537290;-226,44;#NV;-226,44;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202537832;-129,35;#NV;-129,35;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202538445;-227,39;#NV;-227,39;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202538446;-358,96;#NV;-358,96;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202538774;-87,9;#NV;-87,9;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt) +202539011;-239,65;#NV;-239,65;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt) +202539399;-248;#NV;-248;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202537251;#NV;-42,3;42,3;Fehler;Test;Testdaten fehlerhaft: Logik;von 8125 auf 8400 gebucht, obwohl spanische USt-IdNr. Vorhanden, diese ist ungültig +202536817;-230,28;-1434,71;1204,43;1204,43;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202536968;-167,67;-1044,69;877,02;877,02;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537208;-122,52;-101,68;-20,84;-20,84;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537532;-172,55;-1075,04;902,49;902,49;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537567;-18,32;-86,72;68,4;68,4;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537572;-177,06;-90,34;-86,72;-86,72;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537808;-86,22;-86,13;-0,09;-0,09;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,09 EUR) +202538011;-259,12;-239,65;-19,47;-19,47;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202538182;-231,04;-134,54;-96,5;-96,5;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202538332;-40,84;-4,12;-36,72;-36,72;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht \ No newline at end of file diff --git a/dev/2026-07-17-steuern/INFO-Steuerberater.md b/dev/2026-07-17-steuern/INFO-Steuerberater.md new file mode 100644 index 00000000..9c658dd8 --- /dev/null +++ b/dev/2026-07-17-steuern/INFO-Steuerberater.md @@ -0,0 +1,135 @@ +# Rückmeldung zum Abgleich 08+09/2025 (DATEV-Export MIVITA) + +**Datum:** 17.07.2026 +**Bezug:** Abgleich-Listen zu den Konten 4764, 8125, 8300 und 8400 für August/September 2025 + +Vielen Dank für den detaillierten Abgleich. Alle gemeldeten Abweichungen wurden im System +nachvollzogen. Es handelte sich um **fünf technische Ursachen im Export bzw. in der +Datenhaltung** — die Buchungen im Steuerbüro waren in nahezu allen Fällen korrekt, die +Testdaten (unser DATEV-Export) fehlerhaft. Die Ursachen sind behoben; die Exporte für +08/2025 und 09/2025 werden neu erstellt und ersetzt. + +--- + +## 1. Beträge über 1.000 € waren im Export drastisch zu niedrig + +**Betraf:** 202536817, 202536968, 202537532 (Konto 8400) + +Ein Formatierungsfehler beim Speichern der Steuer-/Nettobeträge (Tausendertrennzeichen) +führte dazu, dass z. B. aus 1.434,70 € netto im Export 1,43 € wurden. Die Rechnungen +selbst (PDF/Beleg) waren korrekt — nur der Exportbetrag war falsch. + +**Jetzt:** Der Fehler ist an der Quelle und im Export behoben. Im neuen Export stimmen +diese Positionen mit den Belegen überein. + +--- + +## 2. Kontenzuordnung 8125 (steuerfreie igL) ↔ 8400/8300 + +**Betraf u. a.:** 202536848, 202536858, 202536910, 202537173, 202537290, 202538011, +202538144, 202538445, 202538446, 202539399 (gehörten auf 8125) sowie +202536841, 202537251, 202537460, 202537543, 202538398 (gehörten auf 8400/8300) + +Zwei Ursachen: + +1. Der Export ermittelte die Steuerbehandlung aus dem **heutigen** Kundenstammsatz statt + aus der tatsächlichen Rechnungsstellung. Hatte ein Berater seine USt-ID nach dem Kauf + wieder entfernt, buchte der Export die (korrekt steuerfrei ausgestellte) Rechnung + fälschlich auf 8400 — und umgekehrt. +2. Hinterlegte USt-IDs wurden **nicht validiert**: Auch deutsche USt-IDs, offensichtliche + Platzhalter (z. B. „DE123456789") und formal ungültige Nummern führten zur + igL-Buchung auf 8125. + +**Jetzt:** +- Maßgeblich für die Kontenzuordnung ist, **wie die Rechnung tatsächlich ausgestellt + wurde**: ohne USt verkaufte EU-Lieferungen → 8125 (BU 1); mit USt berechnete + Lieferungen → 8400/8300 nach Steuersatz. Der Export entspricht damit dem Beleg. +- **Deutsche USt-IDs werden systemweit nicht mehr als Grundlage für steuerfreie + Lieferungen akzeptiert** (weder bei der Eingabe noch im Export). +- Die USt-ID wird **bei jeder Bestellung erneut live über die EU-Datenbank (VIES) + geprüft**. Ist sie ungültig oder abgelaufen, wird die Bestellung automatisch **mit + Umsatzsteuer** berechnet und der Kunde darauf hingewiesen. Der Fall „einmal validiert, + dauerhaft steuerfrei trotz abgelaufener ID" (z. B. ESY9284953M, validiert 2023) kann + damit nicht mehr auftreten. + +--- + +## 3. Doppelt vergebene Rechnungsnummern + +**Betraf:** 202537208, 202537572, 202538182, 202538332 + +Diese vier Nummern wurden im Zeitraum tatsächlich **jeweils für zwei verschiedene +Rechnungen** vergeben (technischer Fehler bei zeitgleicher Rechnungserstellung). Im +Export erschienen deshalb unter einer Belegnummer die Positionen beider Rechnungen — +daher die nicht zuordenbaren Beträge. + +**Jetzt:** Die Nummernvergabe ist technisch gegen Doppelvergabe gesichert (für Rechnungen +bereits seit längerem, jetzt auch für Gutschriften, die denselben Nummernkreis nutzen). +Die vier Altfälle bleiben in den Daten bestehen; im neuen Export sind die betroffenen +Positionen weiterhin unter derselben Nummer als getrennte Zeilen erkennbar. Falls für die +Verbuchung eine Aufstellung der jeweils zwei Einzelrechnungen benötigt wird, liefern wir +diese gern nach. + +--- + +## 4. Gutschriften auf Konto 4764 (GS202536995, GS202538597) + +Beide Provisionsgutschriften wurden **ohne Umsatzsteuer** ausgestellt (Empfänger nicht +USt-pflichtig / Ausland). Der Export übergab sie jedoch mit dem Steuerschlüssel für 19 %, +wodurch DATEV rechnerisch die USt herausrechnete (56,70 statt 67,47 bzw. 12,50 statt +14,88). + +**Jetzt:** Der Steuerschlüssel wird aus dem Steuerstatus **zum Zeitpunkt der +Gutschriftserstellung** übernommen — also so, wie er auf dem Beleg ausgewiesen ist. + +--- + +## 5. Stornierte Rechnungen aus 2025 + +**Betraf:** 202536978, 202537144, 202539501 (voll storniert), 202537513 (teilstorniert) + +Diese Stornierungen wurden 2025 außerhalb des Systems abgewickelt und sind dort nicht +als Storno erfasst; der Export enthielt daher die vollen Beträge. Systemseitige +Stornorechnungen gibt es erst seit Anfang 2026 — seither erscheinen Stornos automatisch +als eigene Belegzeilen im Export. + +**Für die Altfälle:** Die Verbuchung im Steuerbüro (Betrag 0 bzw. Teilbetrag) ist +korrekt; hier bitten wir, die Abweichung zum Export einmalig manuell zu berücksichtigen. + +--- + +## 6. Belege außerhalb unseres Nummernkreises + +**Betraf:** 202506145, 202506147 (ggf. weitere, inkl. einzelner Stornorechnungen) + +Diese Rechnungen stammen **nicht aus dem Shopsystem**, sondern aus einem separaten +Rechnungskreislauf des Unternehmens. Sie können und werden im DATEV-Export des Shops +nicht auftauchen und sollten im Abgleich als „extern" gekennzeichnet werden. + +--- + +## Einzelfälle + +- **202539011** („Rechnung liegt uns nicht vor"): Die Rechnung existiert im System + (Positionen 66,22 € zu 7 % und 285,18 € zu 19 %); das PDF wird nachgereicht. +- **Cent-Differenzen (0,01–0,09 €**, z. B. 202537808, 202538011, 202538182): + Rundungsdifferenzen bei der Netto-Aufteilung je Steuersatz; im neuen Export + weitgehend bereinigt, Restdifferenzen im Centbereich können vereinzelt verbleiben. + +--- + +## Zusammenfassung / nächste Schritte + +| Thema | Status | +| --- | --- | +| Beträge > 1.000 € | behoben, neuer Export korrekt | +| Kontenzuordnung 8125/8400/8300 | behoben, folgt jetzt dem Beleg | +| USt-ID-Prüfung | ab sofort Live-Prüfung (VIES) bei jeder Bestellung, keine deutschen USt-IDs | +| Doppelte Rechnungsnummern | Ursache behoben; 4 Altfälle dokumentiert | +| Gutschriften 4764 | behoben, Steuerschlüssel laut Beleg | +| Stornos 2025 | Altfälle: bitte manuell berücksichtigen; ab 2026 automatisch im Export | +| Externe Belege | nicht Teil des Shop-Exports, als „extern" markieren | + +Die **korrigierten DATEV-Exporte für 08/2025 und 09/2025** werden nach Einspielen der +Änderungen neu generiert und bereitgestellt. Bei Rückfragen zu einzelnen Belegen liefern +wir gern Detailauswertungen nach. diff --git a/dev/2026-07-17-steuern/UMSETZUNG-fixes.md b/dev/2026-07-17-steuern/UMSETZUNG-fixes.md new file mode 100644 index 00000000..dcd3a314 --- /dev/null +++ b/dev/2026-07-17-steuern/UMSETZUNG-fixes.md @@ -0,0 +1,181 @@ +# Steuerberater-Abgleich 08+09/2025 — Analyse & Umsetzung der Fixes + +**Datum:** 17.07.2026 +**Anlass:** Korrektur-CSVs des Steuerberaters (`Abgleich 08._09.2025-Konto-*.csv` in diesem Ordner) zum DATEV-Export aus `admin/payments/taxadvisor`. +**Betroffene Exporte:** `datev_exports` #9 (08/2025) und #10 (09/2025), generiert am 08.05.2026. + +--- + +## Lesehilfe zu den Abgleich-CSVs + +Die Spalte **„Betrag Test"** des Steuerberaters ist **nicht** unser Exportbetrag, sondern der +Saldo auf dem Erlöskonto **nach DATEV-Steuerautomatik**: Wir exportieren Bruttobeträge mit +BU-Schlüssel (9 = 19 %, 8 = 7 %), DATEV rechnet die USt automatisch heraus. + +> Beispiel: Export 277,92 € brutto (BU 9) → 233,55 € netto auf 8400 = „Betrag Test". + +Mit diesem Schlüssel lassen sich fast alle gemeldeten Zeilen exakt nachvollziehen. + +--- + +## Befund 1: Beträge ≥ 1.000 € massiv zu niedrig (kritisch) + +**Gemeldete Fälle:** 202536817 (1.434,71 statt 230,28), 202536968 (1.044,69 statt 167,67), 202537532 (1.075,04 statt 172,55) + +**Ursache:** `ShoppingOrder::makeTaxSplit()` speicherte `net_split`/`tax_split` mit +`number_format($value, 2)` — also **mit Tausendertrennzeichen** (`"1,434.70"`). +`DatevExportService::parseNumber()` machte daraus per `str_replace(',', '.')` den String +`"1.434.70"` → `floatval()` = **1,43 €**. Jede Rechnung mit ≥ 1.000 € Netto pro Steuersatz +war im Export um den Faktor ~1000 zu niedrig. + +**Fix:** +- `app/Services/DatevExportService.php` → `parseNumber()` erkennt jetzt US-Format + (`1,434.70`), deutsches Format (`1.434,70`) und reine Tausendertrenner (`1,434`) korrekt. + Nötig, weil die **Altdaten in der DB** das fehlerhafte Format behalten. +- `app/Models/ShoppingOrder.php` → `makeTaxSplit()` und `makeHomepartyTaxSplit()` schreiben + neu mit `number_format($value, 2, '.', '')` (ohne Tausendertrenner). + +--- + +## Befund 2: Falsche Kontenzuordnung 8125 (igL) ↔ 8400/8300 + +**Gemeldete Fälle (Auswahl):** 202536848, 202536858, 202536910, 202537173, 202537290, +202538011, 202538144, 202538445/446, 202539399 (gehörten auf 8125, standen auf 8400) — +umgekehrt 202536841, 202537251, 202537460, 202537543, 202538398 (standen auf 8125, gehörten auf 8400/8300). + +**Ursachen (mehrere):** + +1. **Export nutzte den aktuellen Account-Zustand statt des Rechnungszustands.** + Die betroffenen Berater (z. B. User 781, 850, 974) hatten ihre USt-ID nach dem Kauf + wieder gelöscht (`reverse_charge_delete` leert alle Felder). Die Rechnungen wurden + damals korrekt **ohne USt** (igL) ausgestellt, der Export fand aber keine USt-ID mehr + und buchte auf 8400. +2. **Keinerlei Validierung der USt-ID:** `hasValidUstid` prüfte nur „nicht leer". + Dadurch lösten auch **deutsche** USt-IDs (202536841: `DE296797691`), offensichtliche + Dummy-IDs (202538398: `DE123456789`) und formal ungültige Werte + (202537460: `53618009` — gar kein USt-ID-Format) eine igL-Buchung auf 8125 aus. +3. **Abgelaufene/ungültige IDs:** Die VIES-Prüfung lief nur **einmalig bei der Eingabe** + (202537251: `ESY9284953M`, validiert 08/2023, laut StB inzwischen ungültig). + +**Datenlage als Beleg:** In 08+09/2025 hat **jede** DE-Bestellung ein `tax_split`; +EU-Bestellungen **ohne** `tax_split` sind exakt die steuerfrei verkauften (AT: 71, ES: 14). + +**Fix:** +- `app/Services/DatevExportService.php` → `determineRevenueAccount()`: Maßgeblich ist + jetzt, **wie die Rechnung tatsächlich ausgestellt wurde**: + - EU-Bestellung **ohne** `tax_split` (= ohne USt verkauft) → 8125 / BU 1 (igL) — + unabhängig vom heutigen Account-Zustand. + - EU-Bestellung **mit** `tax_split` (= mit USt berechnet) → 8400/8300 nach Steuersatz, + nie 8125. + - Drittland unverändert 8120 / BU 11. +- `resolveEuUstid()` verwirft deutsche und formal unplausible USt-IDs (neuer zentraler + `App\Services\VatIdService`). Fehlt bei einer igL-Zeile die USt-ID, erscheint wie bisher + eine Validierungs-Warnung im Export (`validation_summary`). + +--- + +## Befund 3: „Betrag fehlerhaft" durch doppelte Rechnungsnummern + +**Gemeldete Fälle:** 202537208, 202537572, 202538182, 202538332 — exakt die vier Nummern, +die in 08+09/2025 **doppelt vergeben** wurden (je zwei verschiedene Rechnungen/Bestellungen). +Der StB sah unter einer Belegnummer die Summe zweier Rechnungen. + +**Ursache:** Historisches Race bei der Nummernvergabe (Zähler-Setting `invoice-number`). +Für **Rechnungen** ist das seit `Invoice::makeNextInvoiceNumber()` behoben +(`DB::transaction` + `lockForUpdate`). **Gutschriften** nutzten aber denselben Zähler +weiterhin **ungesichert**: `CreditRepository::create()` las die Nummer (plain read) und +zählte erst viel später in `finishUserCredit()` hoch — dazwischen konnten parallele Läufe +dieselbe Nummer ziehen. Folge in den Bestandsdaten: 38 Gutschriften teilen sich ihre +laufende Nummer mit einer Rechnung. + +**Fix:** +- `app/Services/Credit.php` → `makeNextCreditNumber()` delegiert an + `Invoice::makeNextInvoiceNumber()` (atomar, gelockt). +- `app/Repositories/CreditRepository.php` → Nummer wird direkt bei der Erstellung atomar + reserviert; das nachträgliche Hochzählen in `finishUserCredit()` entfällt. + +Bestehende Duplikate in den Altdaten bleiben unverändert (nicht rückwirkend korrigierbar). + +--- + +## Befund 4: Gutschriften auf 4764 mit falschem BU-Schlüssel + +**Gemeldete Fälle:** GS202536995 (67,47 statt 56,70), GS202538597 (14,88 statt 12,50) + +**Ursache:** Beide Gutschriften wurden mit `taxable = 3` (nicht USt-pflichtig / Ausland) +ausgestellt — der Beleg weist **keine USt** aus. Der Export leitete den BU-Schlüssel aber +vom **aktuellen** Account-Status ab und exportierte BU 9 (19 %) → DATEV rechnete fälschlich +19 % aus dem steuerfreien Betrag heraus. + +**Fix:** +- `app/Services/DatevExportService.php` → `determineCommissionTaxStatus()` nutzt jetzt den + `taxable`-Snapshot der Gutschrift (1 = BU 9, 2 = Kleinunternehmer BU 50, 3 = Ausland → + Reverse Charge BU 94 bei EU). Nur Alt-Gutschriften ohne Snapshot (`taxable = 0`) fallen + auf die Account-Logik zurück. Der Brutto-Aufschlag nutzt den in der Gutschrift + gespeicherten `tax_rate`. + +--- + +## Befund 5: USt-ID-Prüfung nur einmalig (Anforderung Kevin) + +**Anforderung:** USt-ID bei **jeder Verwendung** prüfen (v. a. im Checkout); bei ungültiger +ID Hinweis anzeigen und mit USt berechnen; **deutsche USt-IDs nie anerkennen**. + +**Umsetzung — neuer zentraler Service `app/Services/VatIdService.php`:** +- `normalize()`, `isPlausibleFormat()` (EU-Länderpräfix + Nummernteil), + `qualifiesForReverseCharge()` (Format ok **und** nicht `DE...`), + `checkVies()` (SOAP gegen `ec.europa.eu` VIES, Timeout konfigurierbar), + `checkViesCached()` (24 h Cache; VIES-Ausfall nur 10 Min. gecacht). +- **Eingabe im Account** (`UserRepository::reverse_charge_activate`): + nutzt den Service; deutsche USt-IDs → eigene Fehlermeldung + (`msg.VATID_german_not_allowed`, in de/en/es/fr ergänzt). SSL-Verifikation ist jetzt + aktiv (vorher deaktiviert). +- **Checkout** (`UserService::performUserTaxShippingCountry`): Wenn Reverse-Charge greifen + würde, wird die USt-ID live (gecacht) geprüft: + - **ungültig / DE / Format falsch** → Reverse-Charge-Status wird entzogen + (`reverse_charge = 0`, `reverse_charge_valid = null`; die USt-ID selbst bleibt zur + Neu-Validierung erhalten), Growl-Hinweis + `msg.VATID_no_longer_valid_taxes_applied`, Bestellung läuft **mit USt** weiter. + - **VIES nicht erreichbar** → gespeicherter Status gilt (Checkout wird nie blockiert), + Log-Warnung. +- **Konfiguration:** `services.vies.enabled` (`VIES_ENABLED`, Default `true`) und + `services.vies.timeout` (`VIES_TIMEOUT`, Default 10 s). In `phpunit.xml` deaktiviert. + +--- + +## Nicht im Code fixbar / offene Punkte + +| Punkt | Status | +| --- | --- | +| **202506145 / 202506147**: existieren nicht in `user_invoices` — stammen aus einem **fremden Nummernkreis** des Kunden (dort werden teils eigene Rechnungen + Stornorechnungen erstellt) | Im Abgleich ignorieren und als „extern" markieren (Vorgabe Kevin) | +| **Stornos 2025** (202536978, 202537144, 202537513 teilweise, 202539501): im System nicht als Storno erfasst — Stornorechnungen (`cancellation = 1`) existieren erst ab 01/2026 | Manuelle Abstimmung mit dem StB / Datenpflege | +| **202535876**: Juli-Rechnung, Gutschrift im September — monatsübergreifende Korrekturen kennt der Export konzeptionell nicht | Offen, bei Bedarf separates Konzept | +| **202539011**: „Rechnung liegt uns nicht vor" — Daten sind im System vorhanden (66,22 € + 285,18 €) | PDF an StB nachliefern | +| **Exporte 08+09/2025 neu generieren**, sobald die Fixes deployed sind | To-do nach Merge | + +--- + +## Tests + +- `tests/Unit/Services/DatevExportServiceTest.php` — erweitert: Tausendertrenner-Parsing, + igL-Semantik (`soldTaxFree`), Ablehnung deutscher/unplausibler USt-IDs, + `taxable`-Snapshot-Logik der Gutschriften. +- `tests/Unit/Services/VatIdServiceTest.php` — **neu**: Normalisierung, Formatprüfung, + DE-Ablehnung, VIES-Verhalten (deaktiviert/unplausibel). +- `tests/Feature/CheckoutVatIdRevalidationTest.php` — **neu**: 6 Checkout-Szenarien + (VIES ok / ungültig / ausgefallen, DE-ID, Format-Müll, Ländercode-Mismatch). + +Alle 63 betroffenen Tests grün; `./vendor/bin/pint` sauber. Vorbestehende, unabhängige +Testfehler (`InvoiceServiceTest`, `tests/Unit/BusinessPlan/*`, `makeUser()`-Kollision der +Gesamtsuite) bestehen unverändert auch auf dem Stand vor diesen Änderungen. + +## Geänderte Dateien + +- `app/Services/DatevExportService.php` — parseNumber, Kontenlogik, USt-ID-Filter, Gutschrift-BU +- `app/Services/VatIdService.php` — **neu**, zentrale USt-ID-Prüfung +- `app/Services/UserService.php` — Live-Prüfung im Checkout + Entzug + Hinweis +- `app/Services/Credit.php`, `app/Repositories/CreditRepository.php` — atomare Nummernvergabe +- `app/Repositories/UserRepository.php` — VIES via Service, DE-Ablehnung bei Eingabe +- `app/Models/ShoppingOrder.php` — number_format ohne Tausendertrenner +- `config/services.php`, `phpunit.xml` — VIES-Konfiguration +- `resources/lang/{de,en,es,fr}/msg.php` — neue Meldungen diff --git a/dev/2026-07-22/Statistiken kontrolliert.pdf b/dev/2026-07-22/Statistiken kontrolliert.pdf new file mode 100644 index 00000000..9e310a7e Binary files /dev/null and b/dev/2026-07-22/Statistiken kontrolliert.pdf differ diff --git a/dev/2026-07-22/entwicklungskonzept.md b/dev/2026-07-22/entwicklungskonzept.md new file mode 100644 index 00000000..9ed8eb3d --- /dev/null +++ b/dev/2026-07-22/entwicklungskonzept.md @@ -0,0 +1,233 @@ +# Entwicklungskonzept — To-Dos vom 2026-07-22 + +Basis: `dev/2026-07-22/todos.md` (Kundenwünsche). Stand der Code-Analyse: 2026-07-22. +Noch **kein Code geändert** — dieses Dokument ist die Planungsgrundlage. + +Die To-Dos zerfallen in zwei Blöcke: + +- **A — Abo-Verwaltung**: Pause-Funktion, Kündigung +- **B — Statistiken & Dashboard**: Home-Dashboard, VIP-Statistik (`/user/backoffice/statistics`), Abo-Statistik + +Die Führungskräfte-Statistik taucht in der To-Do-Liste dreimal auf (Z. 35–36, 62–64, 67–74) — inhaltlich ist das **eine** Anforderung (→ B7). + +--- + +## Übersicht der Arbeitspakete + +| # | Arbeitspaket | Ist-Stand | Aufwand | Offene Fragen | +|---|---|---|---|---| +| A1 | Abo-Pause (1 Monat, Regeln) | Nur Aktiv/Inaktiv-Toggle, keine Terminverschiebung | mittel | F1, F2 | +| A2 | Abo-Kündigung prüfen/finalisieren | Weitgehend vorhanden (Status 4 + Bestätigung) | klein | F3, F4 | +| B1 | Provision „Stand heute" (Home + Statistik-Übersicht) | Provision nur monatlich per Batch, admin-only | groß | F5, F6 | +| B2 | Abo-Zahlen bei Monatswechsel aktuell | Snapshot friert Bestandszahlen ein | klein–mittel | F7 | +| B3 | Neupartner-Liste: Sponsor + Kundenpunkte | Beides fehlt im Drilldown | klein | – | +| B4 | 1000-Punkte-Shop: eigene Person zählt mit | Nur Team, Root-User ausgeschlossen | klein | – | +| B5 | Top-10 Kundenabos (Anzahl) | Existiert nicht | klein–mittel | F8 | +| B6 | Top-10 Kundenabos (Umsatz/Punkte) | Existiert nicht | klein–mittel | F8 | +| B7 | Führungskräfte-Statistik: 4 Kennzahlen + Summe | Nur eigene Abos + eigene Kundenabos | mittel | F9 | +| B8 | Abo-Gesamtumsatz in der Payline | Existiert nicht | mittel | F10 | +| B9 | „Neue Abos" klickbar → wer sind sie | Nur `+N`-Badge, kein gefilterter Drilldown | klein | – | +| B10 | Payline-Prognose zum 5./10./20./25. | Existiert nicht; Datenbasis vorhanden | mittel–groß | F11, F12 | + +> **Update 2026-07-22:** Alle Fragen wurden vom Kunden beantwortet — Zusammenfassung im Abschnitt **„Entscheidungen"** am Ende. Die Entscheidungen sind in die Arbeitspakete unten eingearbeitet. Einzig F12 bleibt eine dokumentierte Annahme (Vorschlag umgesetzt, Kunde prüft nach Umsetzung — siehe B10). +> +> **Umsetzungsstand (2026-07-22):** ✅ **B3, B4, B9 und A2 sind umgesetzt** (inkl. Tests: `AboOneAboRuleTest`, Erweiterungen in `BackofficeStatisticsDrilldownTest`/`BackofficeStatisticsAccessTest`). Zu A2: Die Ein-Abo-Prüfungen (`AboHelper::userHasAbo/memberHasAbo/hasAboByEmail`) blockierten tatsächlich auch nach Kündigung (`status >= 1` schloss Status 4/5 ein) — behoben; `abo-min-duration` hat jetzt Code-Default 6 (`AboHelper::DEFAULT_ABO_MIN_DURATION`). Offen aus diesem Paket: Produktions-Setting `abo-min-duration` auf 6 prüfen. +> +> ✅ **B1 ist umgesetzt** (Tests: `LiveCommissionServiceTest`) — **damit sind ALLE Arbeitspakete dieses Konzepts umgesetzt.** Neuer `App\Services\BusinessPlan\LiveCommissionService`: Der bestehende Live-`TreeCalcBot`-Lauf berechnet entgegen der ursprünglichen Analyse bereits alle drei Provisionsbestandteile (`commission_shop_sales` in `makeUser()`, `commission_pp_total`/`commission_growth_total` in `calcQualPP()`) — es brauchte nur einen dünnen Service mit 6-h-Cache (F5). Liegt für den Monat ein `UserBusiness`-Datensatz vor (Monats-Batch gelaufen), gilt dieser als final (kein Live-Badge). Anzeige: Kachel „Provision (Stand heute)" auf dem Home-Dashboard für **jeden Berater** (F6) im Umsatz-Cluster (Gesamtwert in €, Live-Badge) + KPI-Card auf der VIP-Statistik-Übersicht mit Aufschlüsselung Payline/Shop/WB. +> +> ✅ **B8 + B10 sind umgesetzt** (Tests: `PaylineForecastServiceTest`): Neuer `App\Services\Abo\PaylineForecastService`. Payline = die ersten N Ebenen der Downline, N kommt live aus dem Provisionslevel (`TreeCalcBot`/`BusinessUserItem::getQualLevelPaylines()`, kein Bot-Umbau nötig); Kontext (N + Mitglieder-IDs) 6 h gecacht. **B8**: Karte „Abo-Umsatz in der Payline" auf der Team-Abo-Seite mit **Soll** (Abo-Punkte pro Zyklus, Berater- + Kundenabos der Payline) und **Ist** (im Monat abgerechnete Abo-Punkte, neues SQL-Fragment `SalesVolumePoints::aboPoints()`). **B10**: vier Prognose-Kacheln auf dem Home-Dashboard (nur laufender Monat): Basis = Kundenumsatz + Payline-Punkte heute, plus kumuliert die bis zum 5./10./20./25. noch offenen Abo-Punkte (`next_date` > heute); vergangene Termine ausgegraut. **F12 wie vorgeschlagen umgesetzt**: eigene Abos + eigene Kundenabos zählen mit — Schalter `PaylineForecastService::INCLUDE_OWN_ABOS` (Default true), ohne Umbau umkehrbar. → **Rückfrage an den Kunden nach Livegang:** Soll das so bleiben oder sollen nur die Payline-Abos zählen? +> +> ✅ **A1 ist umgesetzt** (Tests: `AboPauseTest`): Button „1 Monat pausieren" im Abo-Einstellungs-Modal (Berater + Kunden-Portal, nicht Admin-View), verschiebt `next_date` um genau einen Zyklus auf den gleichen Liefertag des Folgemonats, Abo bleibt aktiv (Status 2). Regeln serverseitig in `AboRepository::pauseOneMonth()`: 3-Tage-Sperre vor Ausführung, Mindestlaufzeit (`abo-min-duration`, wie Kündigung), max. 1 Pause pro X Monate rollierend über **neues Setting `abo-pause-interval-months`** (Code-Default 6, `AboHelper::DEFAULT_PAUSE_INTERVAL_MONTHS`). Neue Felder `last_pause_date`/`pause_count` an `user_abos` + neue Tabelle **`user_abo_status_histories`** — protokolliert jetzt Pause, Kündigung, Deaktivierung und Reaktivierung (wer/wann/View + Terminverschiebung). UI zeigt bei gesperrter Pause den Hinweis „wieder möglich ab …". Sprachkeys in de/en/es/fr ergänzt (du-Form). +> +> ✅ **B2, B5/B6 und B7 sind umgesetzt** (Tests in `BackofficeStatisticsListsTest`): Führungskräfte-Liste mit Teamabos/Team-Kundenabos je Führungskraft (Abschneiden an der nächsten Führungskraft, additive Summen) + Gesamt-Spalte; zwei Top-10-Karten „Kundenabos (Anzahl)" und „Kundenabos (Umsatz)" mit Soll (Abo-Punkte-Bestand) und Ist (abgerechnete Kundenabo-Punkte des Monats); Snapshot-Monate überlagern die Abo-Bestandszahlen (Linien, Summen, Führungskräfte, Top 10) jetzt live — Badge „Abo-Bestände tagesaktuell" in der Übersicht, Monats-Punkte/-Umsätze und „+N neu" bleiben eingefroren. Noch offen: A1 (Pause), dann B8+B10 und B1. + +--- + +## Block A — Abo-Verwaltung + +### A1 — Pause-Funktion (max. 1× pro 6 Monate, verschiebt um 1 Monat) + +**Kundenwunsch:** Pausieren erst nach 6 Monaten möglich, maximal 1× in 6 Monaten, Pause = 1 Monat, die Ausführung verschiebt sich um einen Monat. Button in den Abo-Einstellungen. + +**Ist-Stand:** +- Es gibt heute keine echte Pause. Der Toggle „aktiv" in den Abo-Einstellungen setzt Status 2 ↔ 6 (`AboRepository::updateStatus()`, [AboRepository.php:102-127](app/Repositories/AboRepository.php#L102-L127)). Dabei wird `next_date` **nicht verschoben** — das Abo steht einfach still, bis es jemand wieder aktiviert. +- Sperrfrist existiert: 3 Tage vor Ausführung keine Pause/Kündigung (Setting `abo-lock-days-pause-cancel`). +- Es gibt **keine Historie** für Pause-Aktionen — man kann heute nicht feststellen, wann/wie oft ein Abo pausiert wurde. + +**Konzept:** +1. **Neuer Button „1 Monat pausieren"** im Abo-Einstellungs-Modal ([modal_abo_update.blade.php](resources/views/admin/abo/modal_abo_update.blade.php)) für Berater- und Portal-View (mit Bestätigungsabfrage, analog Kündigen-Button). +2. **Wirkung:** `next_date` wird um genau einen Ausführungs-Zyklus verschoben (nächster `abo_interval`-Tag im Folgemonat via `AboHelper::setNextDate()`). Das Abo bleibt aktiv (Status 2) — kein Status-Wechsel nötig, dadurch bleiben alle Statistiken konsistent. +3. **Neue Felder an `user_abos`** (Migration): `last_pause_date` (Datum der letzten Pause) und `pause_count` — oder alternativ Pause-Einträge in einer kleinen Historie (siehe Punkt 5). +4. **Regeln (serverseitig in `AboHelper`/`AboRepository`, UI blendet Button entsprechend aus):** + - Erlaubt erst ab 6 **bezahlten Lieferungen** (entschieden, F1) — gleiche Basis wie Kündigung: `getCountPaidOrders()` gegen das Setting `abo-min-duration`; vorhandene Mechanik `canCancelAbo()` wird zu `canPauseAbo()` gespiegelt. + - Maximal 1 Pause pro X Monate, **rollierend** (entschieden, F2): `last_pause_date` muss länger als X Monate zurückliegen. X kommt aus einem **neuen Admin-Setting `abo-pause-interval-months`** (Default 6, änderbar — vom Kunden explizit gewünscht). + - Bestehende 3-Tage-Sperre vor Ausführung gilt auch für die Pause. +5. **Historie:** Pause (und idealerweise auch Kündigung, s. A2) wird protokolliert — Vorschlag: kleine Tabelle `user_abo_status_histories` (abo_id, action, actor_user_id, created_at) oder Erweiterung der bestehenden `AboItemHistoryService`-Mechanik um Status-Aktionen. Ohne Log ist die „1× in 6 Monaten"-Regel nicht sauber durchsetzbar und nicht nachvollziehbar. +6. **Selfservice-Regel beachten:** wie bei den Lock-Settings gilt die Prüfung auf Basis des Consultant-Views, kein Admin-Bypass im Selfservice-Bereich (Admin-Bereich darf weiterhin frei agieren). + +**Abgrenzung:** Der bestehende Aktiv/Inaktiv-Toggle bleibt unverändert bestehen (Admin-Werkzeug); die neue Pause ist eine separate, regelgebundene Funktion. + +### A2 — Kündigung nach 6. Lieferung („bitte noch mal genau prüfen") + +**Kundenwunsch:** Kündigung nach der 6. Lieferung, monatlich, über Berater oder Kunden-Login. Button „Abo jetzt kündigen" + Rückfrage, dann Entfernung (Softdelete). + +**Ist-Stand — weitgehend vorhanden:** +- Kündigen-Button mit `confirm()`-Rückfrage existiert im Abo-Einstellungs-Modal für Berater- **und** Portal-View ([modal_abo_update.blade.php:110-116](resources/views/admin/abo/modal_abo_update.blade.php#L110-L116)). +- Mindestlaufzeit wird über `AboHelper::canCancelAbo()` gegen das Setting **`abo-min-duration`** geprüft (Zählung: bezahlte + extern erledigte Lieferungen). Vorher wird der Button gar nicht angezeigt, stattdessen ein Hinweis mit Rest-Lieferungen. +- Kündigung setzt `status = 4` (storniert), `active = false`, `cancel_date = now()` — **kein Softdelete**. 3-Tage-Sperre vor Ausführung greift. + +**Prüf-/Restpunkte:** +1. **Setting-Wert kontrollieren:** `abo-min-duration` hat **keinen Code-Default und keinen Seeder** — fehlt der DB-Eintrag, ist die Mindestlaufzeit faktisch 0. In Produktion prüfen, dass das Setting auf `6` steht, zusätzlich Default `6` im Code hinterlegen. Kündigung ab der 6. **bezahlten** Lieferung ist bestätigt (F4) — bei rechtzeitiger Kündigung findet die 7. Lieferung nicht mehr statt. +2. **Kein Softdelete, Status-Modell bestätigt (F3):** Beim regulären Kündigen bleibt es bei `status = 4` — das Abo bleibt für Statistik/Abrechnung nachvollziehbar und verschwindet aus den aktiven Listen. +3. **Neues Abo nach Kündigung darf nicht blockiert werden (F3, wichtig):** Es gilt „immer nur EIN Abo pro Kunde". Die Prüfung, die ein zweites Abo verhindert, muss gekündigte/beendete Abos (Status 4/5, soft-deleted) **ignorieren** — nach einer Kündigung muss sofort ein neues Abo angelegt werden können. Ist-Verhalten der Ein-Abo-Prüfung analysieren, ggf. fixen, mit Test absichern. +4. **Kündigungs-Historie** analog A1 (wer hat wann gekündigt — Berater oder Kunde) mitloggen. +5. Kleiner UI-Review: Button-Beschriftung/Platzierung ggf. an Kundenwunsch anpassen („Abo jetzt kündigen"). + +--- + +## Block B — Statistiken & Dashboard + +### B1 — Provision „Stand heute" (Home-Dashboard + Statistik-Übersicht) + +**Kundenwunsch:** Auf dem Dashboard (und auf der Übersichtsliste, Z. 46) die aktuelle Provision Stand heute anzeigen. + +**Ist-Stand:** +- Provision wird ausschließlich per Batch `business:store-optimized` nach Monatsende berechnet und in `UserBusiness` gespeichert (`commission_pp_total`, `commission_shop_sales`, `commission_growth_total`). Anzeige heute nur im Admin-Bereich. +- Live läuft im HomeController bereits ein `TreeCalcBot` — aber nur für **Payline-Punkte**, nicht für Provision ([HomeController.php:169-183](app/Http/Controllers/HomeController.php#L169-L183), Cache 6 h). + +**Konzept:** +1. Den vorhandenen Live-`TreeCalcBot`-Lauf so erweitern, dass zusätzlich zu `payline_points` auch die Provisions-Kennzahlen für den User berechnet werden (die Rechenlogik existiert in `TreeCalcBot`/`BusinessStoreOptimized`, muss aber für einen Einzel-User-Livelauf extrahiert werden). +2. Ergebnis im selben 6-h-Cache mitführen (`home_payline_*` → erweitern), damit kein zweiter Baum-Lauf nötig ist. +3. Anzeige: neue Kachel „Provision (Stand heute)" auf dem Home-Dashboard (`_statistics.blade.php`) mit „Live"-Badge + Hinweis „vorläufig, final nach Monatsabschluss"; gleiche Kennzahl als KPI-Card auf der VIP-Statistik-Übersicht. +4. Für abgeschlossene Monate: fertigen Wert aus `UserBusiness` anzeigen (kein Live-Lauf). + +**Entschieden (F5/F6):** Die Kennzahl enthält **alle Provisionsbestandteile** (Payline/Team + Shop + Wachstum), Aktualisierung alle ~6 h reicht, und sie wird **jedem Berater** auf dem Home-Dashboard angezeigt (nicht nur VIP). + +**Risiko/Hinweis:** Die Live-Provision ist rechnerisch immer „vorläufig" (Ränge/Qualifikationen können sich bis Monatsende ändern). Das muss im UI klar gekennzeichnet sein. Aufwand ist der größte im Statistik-Block, weil Provisionslogik bisher nur im Monats-Batch für den Gesamtbaum läuft. Da die Kachel für **alle** Berater kommt, ist die Performance des Live-Laufs (6-h-Cache pro User, Berechnung beim ersten Dashboard-Aufruf) besonders zu beachten. + +### B2 — Abo-Zahlen aktuell halten beim Monatswechsel + +**Befund (Kundenbeobachtung bestätigt):** Der Monats-Snapshot (`backoffice:store-statistics-snapshots`) friert **alle** Kennzahlen ein — auch die Abo-Zahlen (Teamabos, Teamkundenabos, Führungskräfte-Liste). Diese Abo-Zahlen sind aber **Bestandszahlen ohne Monatsbezug** (Zählung `activeAbo` ohne Datumsfilter). Wählt man einen vergangenen Monat, sieht man den eingefrorenen Bestand vom Snapshot-Zeitpunkt statt aktueller Zahlen. Nur die `*_neu`-Zahlen sind echt monatsbezogen. + +**Konzept (vom Kunden bestätigt, F7):** Beim Anzeigen eines Snapshot-Monats die **Bestands-Kennzahlen (Abos) live nachrechnen** und über den Snapshot-Payload legen; die monatsbezogenen Zahlen (Punkte, Neupartner, neue Abos) weiter aus dem Snapshot. So sind Abo-Bestände immer tagesaktuell, historische Monatswerte bleiben stabil. + +### B3 — Neupartner-Liste: Sponsor + tagesaktuelle Kundenpunkte + +**Ist-Stand:** Der Drilldown der Neupartner-KPI-Card zeigt Name, E-Mail, Karriere-Level, Aktiv-seit, Account-Gültigkeit — **weder Sponsor noch Punkte**. + +**Konzept:** `BackofficeDrilldownService::newPartnerRows()` erweitern: +- **Sponsor**: Join auf `m_sponsor` → Sponsorname als Spalte. +- **Kundenpunkte (tagesaktuell)**: `externalPoints`-Summe des laufenden Monats je Neupartner über die vorhandenen `SalesVolumePoints`-SQL-Fragmente. +- View `details.blade.php` um die beiden Spalten ergänzen. Kleiner, klar umrissener Eingriff. + +### B4 — 1000-Punkte-Shop: eigene Person zählt mit + +**Ist-Stand:** `lineBuckets()` beginnt bei den Kindern des Root-Users — der VIP selbst ist in `shop1000Count()` und im Drilldown **nie enthalten** (gleiches Muster auf dem Home-Dashboard via `activeTeamUserIds()`). + +**Konzept:** Für die 1000-Punkte-Kennzahl (KPI-Card, Drilldown, Home-KPI) die Root-User-ID mit in die geprüfte ID-Menge aufnehmen. Erreicht der User selbst ≥ 1000 Punkte, erscheint er in Zählung und Liste (z. B. mit Kennzeichnung „(ich)"). Bewusst **nur** für diese Metrik — die übrigen Teamzahlen bleiben ohne den Root-User. + +### B5 + B6 — Top-10 Kundenabos (Anzahl und Umsatz/Punkte) + +**Kundenwunsch:** Zwei Ranglisten „unabhängig vom Incentive": Top 10 nach Anzahl Kundenabos (z. B. „Platz 1: Monika Kunz, 15 Kundenabos") und Top 10 nach Kundenabo-Umsatz in Punkten. + +**Ist-Stand:** Es gibt bereits das Ranking „Neue Kundenabos" (nur Neuzugänge im Monat). Ein Gesamt-Ranking (Bestand) existiert nicht. + +**Konzept (Entscheidungen aus F8 eingearbeitet):** +- Zwei neue Listen auf der VIP-Statistik-Übersicht, technisch analog `newCustomerAboRanking()` in `BackofficeDashboardService`. **Scope: das eigene Team** des Statistik-Nutzers (nicht unternehmensweit). + - **Top 10 Anzahl:** aktive Kundenabos (`is_for = ot`) je Berater zählen — **Gesamtbestand, unabhängig vom Incentive** —, absteigend, Top 10. + - **Top 10 Umsatz mit zwei Werten „Soll" und „Ist"** (Kundenwunsch): + - **Soll** = Abo-Punkte-Bestand je Berater (Produktpunkte × Menge der laufenden Kundenabos, `getTotalPoints()`-Logik als SQL-Summe) — was die Abos pro Zyklus bringen. + - **Ist** = im gewählten Monat tatsächlich abgerechneter Kundenabo-Umsatz (Abo-Punkte aus `user_sales_volumes` über die `SalesVolumePoints`-Fragmente). + - Sortierung nach Soll, Ist als zweite Wertspalte daneben. +- Darstellung mit Platz-Nummerierung (Platz 1, 2, …), Name, Wert(e); Linie/Level klein darunter wie bei den bestehenden Listen. + +### B7 — Führungskräfte-Statistik: 4 Kennzahlen + Gesamtsumme + +**Kundenwunsch (3× in der Liste genannt, eine Anforderung):** Pro Führungskraft (ab Vertriebsleiter): eigenes Abo, eigene Kundenabos, **Teamabos**, **Kundenabos im Team**, plus **Summe aller 4 Kennzahlen**. + +**Ist-Stand:** Die Liste „Abos Führungskräfte" (`leaderAboOverview()`) zeigt nur die **eigenen** Abos und **eigenen** Kundenabos je Führungskraft. Downline-Aggregate pro Führungskraft gibt es nicht. + +**Konzept:** +1. `leaderAboOverview()` erweitern: pro Führungskraft den eigenen Teilbaum ermitteln (BFS über `m_sponsor`, Mechanik aus `lineBuckets()`/`teamUserIds()` wiederverwendbar) und darauf zählen: + - `team_abo_count`: aktive Berater-Abos (`is_for = me`) der Downline + - `team_customer_abo_count`: aktive Kundenabos (`is_for = ot`) der Downline +2. Neue Spalten + Summenspalte `total = own + own_customer + team + team_customer` in [index.blade.php](resources/views/user/backoffice/statistics/index.blade.php), Sortierung nach Summe. +3. **Performance:** Teilbäume aller Führungskräfte einmalig aus einer gemeinsamen Sponsor-Map im Speicher ableiten (ein Query für alle User-Beziehungen, keine N Abfragen pro Leader). Ergebnis landet mit im Monats-Snapshot (alte Snapshots zeigen dann den bestehenden „bitte Snapshot neu erzeugen"-Hinweis). +4. **Zählweise (entschieden, F9): an der nächsten Führungskraft abschneiden.** Der Teilbaum einer Führungskraft endet, sobald darunter die nächste Führungskraft (ab Vertriebsleiter) beginnt — deren Team zählt nur bei ihr selbst. Vorteil: keine Überlappungen, die Teamzahlen der Führungskräfte sind untereinander additiv. + +### B8 — Abo-Gesamtumsatz in der Payline (Abo-Statistik) + +**Kundenwunsch:** Auf der Abo-Statistik zusätzlich den Abo-Gesamtumsatz **in der Payline** ausweisen. + +**Ist-Stand:** Die Team-Abo-Seite zeigt Abo-Punkte pro Linie und gesamt (Bestandspunkte). Eine Verknüpfung „Abo-Punkte ∩ Payline" existiert nicht; die Payline wird nur im Live-`TreeCalcBot` als Gesamtpunktzahl berechnet. + +**Konzept (entschieden, F10 — „Soll und Ist"):** Die Payline-Berechnung liefert (bzw. kann liefern), welche Linien/User in der Payline des Users liegen. Darauf aufbauend zwei Kennzahlen auf der Abo-Statistik-Seite, gecacht analog `home_payline_*`: +- **Soll** = Summe der aktiven Abo-Punkte aller Payline-Mitglieder (Berater-Abos + Kundenabos), Stand heute. +- **Ist** = im laufenden Monat bereits abgerechneter Abo-Umsatz der Payline-Mitglieder (aus `user_sales_volumes`). + +Voraussetzung ist, dass der Live-Bot die Payline-Mitglieder-IDs exponiert (kleine Erweiterung an `TreeCalcBot`). + +### B9 — „Neue Abos" klickbar: wer sind sie? + +**Ist-Stand:** Die `+N`-Badges (Home-Dashboard-Kacheln und VIP-Übersicht) sind reine Anzeige; die Kacheln verlinken nur auf generische Abo-Listen. Der Abo-Drilldown markiert neue Abos mit „Neu"-Badge, filtert aber nicht. + +**Konzept:** Der vorhandene Abo-Drilldown (`metric=team_partner_abos` / `team_customer_abos`) bekommt einen Filter-Parameter `only_new=1` (Filter auf `start_date` im gewählten Monat — Logik aus `newAboCount()` existiert). Die `+N`-Badges werden zu Links auf den gefilterten Drilldown. Auf dem Home-Dashboard verlinken die Badges auf dieselben Drilldowns. + +### B10 — Payline-Prognose: voraussichtlicher Gesamtumsatz am 5./10./20./25. + +**Kundenwunsch:** Vier Kennzahlen auf der Übersicht (HOME): voraussichtlicher Payline-Gesamtumsatz zum 5./10./20./25. = aktueller Kundenumsatz + Payline-Gesamtumsatz Stand heute **+ Abo-Umsätze der Payline, die bis zum jeweiligen Termin dazukommen**. Ziel: Anfang des Monats absehen, wo die Payline an den Ausführungsterminen stehen wird. + +**Ist-Stand / Datenbasis (gut vorhanden):** +- Feste Ausführungstage existieren als `UserAbo::$aboDeliveryDays = [5, 10, 20, 25]`; jedes Abo trägt `abo_interval` (gewählter Tag) und `next_date` (konkreter nächster Termin). +- Punkte je Abo über `getTotalPoints()` (Produktpunkte × Menge). +- Aktueller Kundenumsatz und Live-Payline-Punkte werden auf dem Home-Dashboard bereits berechnet. +- Eine Auswertung „Abo-Umsätze pro Ausführungstag" existiert noch nirgends. + +**Konzept:** +1. Neuer Service (z. B. `PaylineForecastService`): ermittelt für den laufenden Monat je Termin T ∈ {5, 10, 20, 25}: + `Prognose(T) = Kundenumsatz heute + Payline-Punkte heute + Σ Abo-Punkte aller aktiven Abos in der Payline mit next_date im laufenden Monat und Tag ≤ T, die heute noch nicht ausgeführt sind.` + Bereits ausgeführte Abos stecken automatisch im Ist-Umsatz und werden nicht doppelt gezählt (Abgrenzung über `next_date` > heute). +2. Kumulative Logik (bestätigt, F11): die Prognose zum 25. enthält auch die Termine 5./10./20. (sofern noch offen) — „so steht die Payline am 25.". +3. **Payline-Zugehörigkeit der Abos — Annahme zu F12 (vom Kunden noch nicht final entschieden):** + - **Vorschlag/Umsetzung:** Die **eigenen Abos und eigenen Kundenabos** des Beraters **zählen mit** — zusätzlich zu den Abos der Payline-Downline (Mitglieder-Menge wie B8). Begründung: Die Basis der Formel enthält den aktuellen Kundenumsatz; kommende eigene Abo-Ausführungen erhöhen genau diesen Anteil. Ohne sie wäre die Prognose systematisch zu niedrig. + - **Umsetzungs-Notiz (wichtig):** Die Auswahl wird an EINER Stelle im `PaylineForecastService` gekapselt (Parameter/Konstante `includeOwnAbos`, Default `true`), sodass die Entscheidung später ohne Umbau umkehrbar ist. Nach der Umsetzung bekommt der Kunde eine Notiz, wie es umgesetzt wurde, mit der Frage, ob es so bleiben soll (siehe Entscheidungen, F12). +4. Anzeige: vier Kacheln (oder eine kleine Tabelle „Prognose") auf dem Home-Dashboard mit „Live"-Kennzeichnung; Cache zusammen mit `home_payline_*` (6 h), tagesaktuell wie vom Kunden beschrieben. +5. Ausfall-Risiken (fehlgeschlagene Zahlungen, Pausen, Kündigungen bis zum Termin) machen die Zahl naturgemäß zur **Prognose** — im UI so benennen. + +B8 und B10 teilen sich die Grundlage „Payline-Mitglieder + deren aktive Abos" und sollten zusammen umgesetzt werden. + +--- + +## Empfohlene Umsetzungsreihenfolge + +1. **Schnelle, klar definierte Pakete:** B3, B4, B9, A2 (Prüfung/Feinschliff) +2. **Statistik-Ausbau:** B5/B6, B7, B2 +3. **Abo-Pause:** A1 (nach Klärung F1/F2) +4. **Payline/Provision (aufwändig, gemeinsame Basis):** B8 + B10, danach B1 + +--- + +## Entscheidungen (Kundenantworten vom 2026-07-22) + +| # | Frage (Kurzform) | Entscheidung | +|---|---|---| +| F1 | Pause-Voraussetzung | **6 bezahlte Lieferungen** (wie Kündigung, gegen `abo-min-duration`) | +| F2 | Pause-Zyklus | **Rollierend**: letzte Pause muss X Monate zurückliegen; X als **änderbares Setting** (`abo-pause-interval-months`, Default 6) | +| F3 | Kündigung „Softdelete" | Status-Modell bestätigt (nachvollziehbar für Statistik/Abrechnung). **Wichtig:** Nach Kündigung darf die Ein-Abo-Regel ein **neues Abo nicht blockieren** — gekündigte Abos zählen bei der Prüfung nicht mit | +| F4 | Mindestlaufzeit Kündigung | Bestätigt: ab der 6. **bezahlten** Lieferung; Setting in Produktion auf 6 + Code-Default 6 | +| F5 | Provision live — Umfang | **Alle Bestandteile** (Payline + Shop + Wachstum); Aktualisierung alle ~6 h | +| F6 | Provision live — Sichtbarkeit | **Jeder Berater** (Home-Dashboard) | +| F7 | Abo-Zahlen bei Monatswechsel | Wie empfohlen: Abo-Bestände **live/tagesaktuell**, Monats-Punkte/-Umsätze bleiben eingefroren | +| F8 | Top-10-Listen | a) Scope: **eigenes Team**; b) **Bestand aktiver Kundenabos**, unabhängig vom Incentive; c) Umsatz-Ranking mit **Soll UND Ist** | +| F9 | Verschachtelte Führungskräfte | **An der nächsten Führungskraft abschneiden** (keine Überlappung, Zahlen additiv) | +| F10 | Payline-Abo-Umsatz | **Soll und Ist** (Abo-Punkte-Bestand + abgerechneter Monatsumsatz) | +| F11 | Prognose kumuliert | **Ja**, kumuliert (25. enthält 5./10./20.) | +| F12 | Eigene Abos in der Prognose | **Vom Kunden offen gelassen** — siehe unten | + +### F12 — dokumentierte Annahme (Kunde entscheidet nach Umsetzung) + +Der Kunde konnte nicht entscheiden, ob eigene Abos/eigene Kundenabos des Beraters in die Payline-Prognose (B10) einfließen; wir sollen einen Vorschlag umsetzen und dokumentieren. + +- **Umgesetzt wird (Vorschlag):** Eigene Abos + eigene Kundenabos **zählen mit**, weil die Prognose-Basis den aktuellen Kundenumsatz enthält und kommende eigene Abo-Ausführungen genau diesen erhöhen. Ohne sie wäre die Prognose systematisch zu niedrig. +- **Umkehrbar:** Die Auswahl liegt an einer Stelle im `PaylineForecastService` (Schalter `includeOwnAbos`, Default `true`). +- **Offener Punkt an den Kunden nach Umsetzung:** „Die Prognose zum 5./10./20./25. enthält auch deine eigenen Abos und deine Kundenabos. Soll das so bleiben oder sollen nur die Team-Abos der Payline zählen?" diff --git a/dev/2026-07-22/feedback-an-kundin-statistiken.md b/dev/2026-07-22/feedback-an-kundin-statistiken.md new file mode 100644 index 00000000..7748ee1e --- /dev/null +++ b/dev/2026-07-22/feedback-an-kundin-statistiken.md @@ -0,0 +1,106 @@ +# Rückmeldung zu deinem Dokument „Statistiken kontrolliert" + +Vielen Dank für deine gründliche Kontrolle — sie war sehr hilfreich! Wir sind alle Punkte einzeln durchgegangen und haben jede Zahl mit der Datenbank nachgerechnet. + +**Das Wichtigste vorweg: Alle Zahlen im System stimmen — es gibt keinen Rechenfehler.** An einigen Stellen zählen zwei Ansichten aber bewusst unterschiedlich (das erklären wir unten), und einige deiner Wünsche waren berechtigte Verbesserungen, die wir jetzt umgesetzt haben. Hier die Antworten zu jedem deiner Punkte: + +--- + +## 1. Neue Kundenabos — „hier muss ICH auch in die Liste" + +✅ **Umgesetzt.** Du erscheinst jetzt selbst in der Liste, sobald du neue Kundenabos abgeschlossen hast. Deine Zeile ist mit einem kleinen „ich"-Zeichen markiert. + +## 2. Top 10 Kundenabos (Anzahl) + +✅ **Du bist jetzt dabei:** Wenn du selbst unter den Top 10 bist, erscheinst du in der Liste (ebenfalls mit „ich"-Zeichen). + +**Zu den Zahlen (Rosmarie 14 statt 13, Tanja 16 statt 13 usw.):** Wir haben jede einzelne Zahl nachgerechnet — **beide Listen stimmen**, sie zählen nur zwei verschiedene Dinge: + +- Die **Montenegro-Rangliste** zählt alle Abos, die im Aktionszeitraum (1. April bis 31. Juli) gestartet sind. Was einmal gezählt wurde, bleibt gezählt — auch wenn das Abo inzwischen beendet oder gelöscht wurde. Außerdem zählt dort das **eigene Abo der Beraterin mit** (deshalb „17/6" bei Tanja). +- Die **Top-10-Liste** zeigt dagegen den **aktuellen Bestand**: nur Kundenabos, die heute wirklich laufen. + +Am Beispiel Tanja: Montenegro zeigt 17 = 13 heute laufende Kundenabos + 1 eigenes Abo + 3 Abos, die im Aktionszeitraum gestartet, inzwischen aber beendet oder gelöscht wurden. Beide Zahlen sind also richtig — sie beantworten nur verschiedene Fragen („Was wurde für das Incentive geschafft?" vs. „Was läuft heute?"). + +Ein Teil der Differenz kam übrigens von den Kundenabos, die wegen Zahlungsproblemen gelöscht und neu angelegt wurden — die alten zählen im Incentive weiter, im Bestand natürlich nicht. Damit das nicht mehr verwirrt, steht der Unterschied jetzt direkt als Hinweis an der Top-10-Liste. Zusätzlich haben wir intern dafür gesorgt, dass gelöschte Abos sauber als beendet markiert werden. + +## 3. Top 10 Kundenabos (Umsatz) + +✅ **Umgesetzt, genau wie gewünscht:** + +- Die Liste zeigt jetzt als Hauptwert die **im laufenden Monat bereits angerechneten Kundenabo-Punkte** — danach wird auch sortiert. +- Daneben steht jetzt die **Anzahl der Kundenabos**, mit denen die Beraterin diesen Umsatz gemacht hat (dein Beispiel: „Tanja: 1.123 Punkte mit 16 Kundenabos"). +- Du selbst erscheinst ebenfalls, wenn du unter den Top 10 bist. + +**Zu deiner Frage, warum „Soll" mal höher und mal niedriger war als „Ist":** Der bisherige „Soll"-Wert war der heutige Abo-Bestand — also was die aktuell laufenden Abos pro Lieferung bringen. Der „Ist"-Wert ist, was im Monat schon angerechnet wurde. Wenn ein Abo Anfang des Monats noch geliefert wurde und danach beendet wurde, steckt es im Ist, aber nicht mehr im Bestand — dann ist Ist höher. Kommt ein neues Abo dazu, dessen Liefertag noch aussteht, ist es umgekehrt. Beide Werte sind weiterhin sichtbar, aber jetzt klar beschriftet: **„Abgerechnet"** und **„Bestand/Lieferung"**. + +## 4. Abos Führungskräfte — „stimmen leider nicht" + +Hier stimmen die Zahlen — die Liste hat aber anders gezählt, als du erwartet hast, und zwar so, wie es ursprünglich gemeinsam festgelegt wurde: Jede Führungskraft zählte ihr Team nur **bis zur nächsten Führungskraft**. Der Grund: So wird kein Abo doppelt gezählt, und die Gesamtsumme oben geht exakt auf. + +Deine Rechnung zählt dagegen die **komplette Downline** — das haben wir nachgerechnet und es stimmt exakt (Nina: 21 Teamabos + 52 Teamkundenabos, genau wie von dir angegeben; Rosmarie lag bei unserer Prüfung bei 82 + 201, dein Stand 81 + 200 war vom Vortag). + +✅ **Umgesetzt — die Tabelle zeigt jetzt beides:** + +- **„direkt"** = das eigene Team bis zur nächsten Führungskraft (diese Spalten gehen in der Summe oben exakt auf), +- **„gesamt"** = die komplette Downline, so wie du gezählt hast. Die **Gesamt-Spalte** rechnet jetzt genauso wie du: eigenes Abo + Kundenabos + alle Teamabos + alle Teamkundenabos. + +Wichtig zu wissen: In der „gesamt"-Sicht zählt dasselbe Abo bei mehreren Führungskräften mit (Ninas Team steckt z. B. auch komplett in Rosmaries Zahlen). Deshalb kann man diese Spalten nicht einfach aufaddieren — ein kurzer Hinweis dazu steht unter der Tabelle. Schau es dir bitte an und gib uns Bescheid, ob dir beide Sichten helfen oder ob eine davon reicht. + +## 5. Statistik 97.453 vs. Struktur 92.211 — „theoretisch gleich, warum verschieden?" + +Beide Zahlen sind richtig — sie enthalten unterschiedliche Punkte: + +- Die **Statistik** zählt **alle** Umsatzpunkte deines Teams. +- Die **Struktur-/Provisionsansicht** zählt nur die Punkte, die in deine **Payline** fließen und damit provisionsrelevant sind. + +Der Unterschied sind Punkte, die einer Beraterin persönlich gutgeschrieben werden, aber nicht in die Payline wandern — vor allem die **Einschreibungspunkte für Neupartner** (bei deiner Kontrolle: 19 Neupartner-Einschreibungen plus eine Gutschrift, zusammen genau die Differenz). + +✅ **Umgesetzt:** Unter der Statistik-Tabelle steht jetzt ein Hinweis, wie viele der Gesamtpunkte nicht payline-wirksam sind — dann sieht man auf einen Blick, warum die Struktur-Ansicht niedriger liegt. + +## 6. Team-Abos: „Was bedeuten diese 42.628 Abo-Punkte?" + +Diese Zahl ist der **Bestand**: die Punkte, die alle aktuell laufenden Abos deines Teams **pro Lieferung** bringen würden — über alle 10 Ebenen deines Teams. (Die Payline-Karte darüber zeigt weniger, weil deine Payline nur die ersten 6 Ebenen umfasst.) Es ist also nicht der abgerechnete Umsatz. + +✅ **Umgesetzt — die Tabelle „Abos nach Ebenen" zeigt jetzt drei Werte pro Ebene:** + +1. **Punkte pro Lieferung (Bestand)** — die bisherige Zahl, jetzt klar beschriftet, +2. **abgerechnet (Monat)** — was in diesem Monat schon angerechnet wurde, +3. **bis zum 25. erwartet** — was bis zum letzten Liefertag des Monats voraussichtlich zusammenkommt (bereits Abgerechnetes plus die noch anstehenden Lieferungen). + +Damit hast du genau die Sicht, die du dir gewünscht hast: was zum Stichtag 25. im aktuellen Monat abgerechnet sein wird. + +## 7. Payline: „Wieviel Abos gibt es in der Payline?" + +✅ **Umgesetzt.** Unter der Payline-Karte steht jetzt zusätzlich, wie viele Abos dahinterstecken, bei deinem Account aktuell: „Payline = 6 Ebenen · 502 Berater — 129 Teamabos + 340 Kundenabos in der Payline." + +## 8. Live-Rangliste Montenegro — Nummerierung + +✅ **Umgesetzt, genau wie von dir beschrieben:** Die Plätze werden jetzt so vergeben, dass die **qualifizierten** Beraterinnen oben stehen und fortlaufend Platz 1, 2, 3, … tragen. Wer mehr Punkte hat, aber die Mindestqualifikation (Partner/Abos) noch nicht geschafft hat, wird **darunter** eingereiht — und springt nach oben, sobald die Qualifikation erreicht ist. Dein Beispiel: Tanja steht mit den meisten Punkten, aber ohne Mindestqualifikation, jetzt auf Platz 5 — nicht auf Platz 1. + +Der Fix hatte noch einen positiven Nebeneffekt: Die Gewinner-Ermittlung („die besten 20 qualifizierten") arbeitet jetzt ebenfalls sauber nach dieser Reihenfolge. + +## 9. Portokosten Deutschland: Abo 19,90 € vs. Einzelbestellung 16,72 € + +Gute Nachricht: **Es gibt gar keinen Preisunterschied.** Beide Zahlen sind derselbe Versandsatz — einmal **mit** Mehrwertsteuer (19,90 €) und einmal **ohne** (16,72 €; 16,72 € + 19 % MwSt = 19,90 €). Die Abo-Ansicht zeigte den Betrag inklusive Steuer, die Einzelbestellungs-Übersicht ohne — deshalb sah es wie ein Aufschlag aus. + +In Österreich fällt der Unterschied nicht auf, weil dort für Teampartner mit gültiger UID-Nummer keine deutsche Mehrwertsteuer berechnet wird (Reverse Charge) — darum stand dort in beiden Ansichten derselbe Betrag (25,21 €). + +✅ **Umgesetzt:** Die Anzeige ist jetzt einheitlich und eindeutig beschriftet — bei Beratern (die als Selbstständige netto rechnen) steht der Nettobetrag mit dem Zusatz **„(netto)"**, bei Endkunden der Bruttobetrag mit **„(inkl. MwSt.)"**. Abgebucht wurde immer der korrekte Betrag; es war rein eine Darstellungsfrage. + +## 10. Struktur: Kennzeichen für Abos je Teampartner + +✅ **Umgesetzt.** In der Struktur-Ansicht steht jetzt bei jedem Teampartner: + +- ein grünes **„A"**, wenn er/sie ein eigenes laufendes Abo hat, +- daneben eine rote Zahl, z. B. **(8)**, für die Anzahl der eigenen Kundenabos. + +Beim Drüberfahren mit der Maus wird die Bedeutung angezeigt. Gezählt wird genauso wie in der Statistik, damit die Zahlen überall zusammenpassen. + +--- + +## Was wir noch von dir brauchen + +1. **Führungskräfte-Liste (Punkt 4):** Schau dir bitte die neue Tabelle mit den beiden Sichten „direkt" und „gesamt" an — reicht dir eine davon oder sollen beide bleiben? +2. **Payline-Vorschau (offen aus der letzten Runde):** In der Vorschau auf die Liefertage zählen aktuell auch deine **eigenen** Abos und Kundenabos mit. Soll das so bleiben oder sollen nur die Team-Abos der Payline zählen? + +Alle Änderungen sind auf dem Testsystem bereits aktiv — du kannst alles direkt mit deinem Zugang nachprüfen. Melde dich gern, wenn eine Zahl nicht zu deiner Erwartung passt: Wie du an den Punkten oben siehst, lohnt sich das Nachrechnen immer — entweder finden wir eine Verbesserung oder eine gute Erklärung. diff --git a/dev/2026-07-22/feedback-analyse-2026-07-24.md b/dev/2026-07-22/feedback-analyse-2026-07-24.md new file mode 100644 index 00000000..ca08b2f0 --- /dev/null +++ b/dev/2026-07-22/feedback-analyse-2026-07-24.md @@ -0,0 +1,261 @@ +# Analyse: Feedback „Statistiken kontrolliert" (2026-07-24) + +Basis: `dev/2026-07-22/Statistiken kontrolliert.pdf` (Kundin, getestet mit User **454** / info@claudialattner.com). +Alle Zahlen wurden am **2026-07-24** gegen die aktuelle Test-DB verifiziert (Datenstand von heute). + +> **Umsetzungsstand (2026-07-24): ✅ ALLE Punkte sind gemäß den „++"-Antworten des Kunden umgesetzt** — +> Details je Punkt im jeweiligen Abschnitt „Umgesetzt". Tests: `BackofficeStatisticsListsTest` (12), +> `TeamAboOverviewTest` (11), `PaylineForecastServiceTest` (6), `AboAdminDeleteTest` (3), +> `Incentive/IncentiveRankingTest` (4, neu), `TeamStructureAboBadgesTest` (5, neu), +> `YardShippingTaxTest` (neu) + erweiterte `AboOneTimeViewTest` — alle grün. +> Einziger roter Test im Umfeld: `IncentiveTeaserPageTest` (Galerie-Bild `nikki-beach.jpg` liegt lokal im +> Ordner) — vorbestehend, ohne Bezug zu diesen Änderungen. + +**Gesamtbild:** Von den 10 Punkten sind **2 echte Fixes** (Rang-Nummerierung Incentive, Porto-Anzeige), +**3 kleine Erweiterungen** („ich" in Listen, Abo-Zähler Payline, Struktur-Symbole), +**2 Erklärungs-/Beschriftungsthemen** (Zahlen stimmen, sind aber unterschiedliche Metriken) +und **1 Konzeptänderung** (Führungskräfte: komplette Downline statt Abschneiden — widerspricht der früheren Entscheidung F9). +Alle vom Kunden gemeldeten „falschen" Zahlen ließen sich vollständig aufklären — **kein Rechenfehler in den neuen Statistiken**. + +| # | Punkt | Einstufung | Aufwand | +|---|---|---|---| +| 1 | „Neue Kundenabos": ich selbst fehlt | Erweiterung (bewusster Ist-Stand) | klein | +| 2a | Top 10 Anzahl: ich selbst fehlt | Erweiterung | klein (mit 1) | +| 2b | Top 10 Anzahl ≠ Montenegro-Zahlen | **kein Bug** — andere Metrik, erklärbar | Doku/UI-Text | +| 3 | Top 10 Umsatz: Soll/Ist unklar, Abo-Anzahl fehlt | Erweiterung + Beschriftung | klein | +| 4 | Führungskräfte-Zahlen „stimmen nicht" | **Konzeptänderung** (F9 wird revidiert) | klein–mittel + Rückfrage | +| 5 | Statistik 97.453 ≠ Struktur 92.211 | **kein Bug** — Neupartner-Punkte sind nicht payline-wirksam | Doku/UI-Text | +| 6 | Was bedeuten 42.628 Abo-Punkte? | Erklärung + sinnvolle Erweiterung (Ist je Ebene) | klein–mittel | +| 7 | Payline: Anzahl Abos anzeigen | Erweiterung | klein | +| 8 | Montenegro: Nummerierung falsch | **Fix** (Ranking-Sortierung) | klein, aber mit Nebenwirkungen prüfen | +| 9 | Porto DE Abo 19,90 vs. Einzel 16,72 | **Anzeige-Fix** (brutto vs. netto) | klein | +| 10 | Struktur: Abo-Symbole je Teampartner | neue Anforderung | mittel | + +--- + +## 1. „Neue Kundenabos": eigene Person muss erscheinen + +**Feedback:** „Hier muss ICH auch in die Liste aufgenommen werden, sollte ich neue Kundenabos abgeschlossen haben." + +**Befund/Ursache:** Alle Team-Auswertungen der VIP-Statistik basieren auf `lineBuckets()` +([BackofficeDashboardService.php:215-240](app/Services/Backoffice/BackofficeDashboardService.php#L215-L240)). +Dort steht der Root-User von Anfang an in `visitedUserIds` und wird per `whereNotIn('id', …)` in **jeder** Linie ausgeschlossen. +Damit fehlt die eingeloggte Person in „Neue Kundenabos" (`newCustomerAboRanking()`, Z. 499-533), in beiden Top-10-Listen und in der Führungskräfte-Liste. +Einzige bisherige Ausnahme ist `shop_1000` — dort wurde der Root-User auf Kundenwunsch (B4) explizit ergänzt (Z. 96-98, Muster „(ich)" existiert also schon). + +Hinweis: User 454 hat aktuell **0 neue Kundenabos im Juli** — sie würde in dieser Liste derzeit ohnehin nicht auftauchen. Die Anforderung ist trotzdem korrekt. + +**Empfehlung:** Root-User-ID in die Kandidatenmenge von `newCustomerAboRanking()` und `topCustomerAboRanking()` aufnehmen (Linie 0 / Markierung „(ich)", analog `shop_1000`). Kleiner, klar umrissener Eingriff + Test. + +++ so umsetzen wie in der Empfehlung + +**Umgesetzt (2026-07-24):** `usersWithLine()` nimmt optional den Root-User als Linie 0 auf; `newCustomerAboRanking()` und `topCustomerAboRanking()` (beide Listen) zählen ihn jetzt mit. Neue Zeilen tragen `is_self`; die View zeigt ein „ich"-Badge und „Eigene Kundenabos" statt „Linie 0". Test: „nimmt den Statistik-Nutzer selbst in die Kundenabo-Ranglisten auf". + +## 2. Top 10 Kundenabos (Anzahl) + +### 2a. Eigene Person fehlt +Gleiche Ursache und Empfehlung wie Punkt 1. Zum Vergleich: User 454 hat aktuell **7 aktive Kundenabos** — wäre also derzeit nicht in den Top 10 (Platz 10 = 10 Abos), muss aber grundsätzlich mitspielen. + +++ so umsetzen wie in der Empfehlung + +**Umgesetzt (2026-07-24):** mit Punkt 1 erledigt (beide Top-10-Listen inkl. „ich"). + +### 2b. „Zahlen stimmen nicht" (Rosmarie 14 statt 13, Tanja 16 statt 13, …) + +**Befund: Beide Zahlen sind korrekt — es sind zwei verschiedene Metriken.** Die Service-Methode liefert heute exakt die Screenshot-Werte (Tanja 13 usw.), und die Montenegro-Werte stimmen ebenfalls exakt mit der DB überein. + +Die Montenegro-Spalte „ABOS" zählt etwas anderes als die Top-10-Liste: + +| | Top 10 Kundenabos (Anzahl) | Montenegro „ABOS x/6" | +|---|---|---| +| Was | **aktueller Bestand** aktiver Kundenabos | **im Qualifikationszeitraum (1.4.–31.7.) aktivierte** Abos | +| Eigenabo des Beraters | zählt nicht (`is_for='ot'`) | zählt mit (+1, inkl. Alt-Eigenabo) | +| Inzwischen gelöschte/inaktive Abos | zählen nicht | bleiben gezählt (Tracking-Tabelle `incentive_new_abos`, einmal erreicht = bleibt) | +| Vor dem Zeitraum angelegte Abos | zählen (solange aktiv) | zählen, wenn **Aktivierung/erste Zahlung** in den Zeitraum fällt | + +Konkret nachgerechnet (DB-Stand heute), Beispiel **Tanja Longerich** (Montenegro 17 vs. Top-10 13): + +- 13 = aktive, nicht gelöschte Kundenabos heute (Top-10-Wert ✓) +- 17 = 13 + **1 Eigenabo** + **2 im Juli gelöschte** Kundenabos (soft-delete am 06.07./21.07., im Mai angelegt) + **1 inzwischen storniertes** Abo (Status 6, Januar-Abo, erste Ausführung im April) + +Analog: Rosa Maria Assenbaum 15 = 13 + 1 Eigenabo + 2 gelöschte (16.07./21.07.) − 1 vor dem Zeitraum aktiviertes; Monika Kunz 16 = 13 + 1 + 2; Melanie Hoffmann-Gebhard 15 = 13 + 1 + 1. +Die Erwartung der Kundin („Tanja 16") ist die Montenegro-Kundenabo-Zahl (17 − 1 Eigenabo) — kein Bestand. + +Code: Top-10 [BackofficeDashboardService.php:764-776](app/Services/Backoffice/BackofficeDashboardService.php#L764-L776) (`active=1`, `status NOT IN (4,5,6)`, SoftDelete-Scope) vs. Incentive [IncentiveParticipant.php:300-354](app/Models/IncentiveParticipant.php#L300-L354) + [IncentiveTracker.php:85-135](app/Services/Incentive/IncentiveTracker.php#L85-L135). + +**Empfehlungen:** +1. **UI-Text schärfen:** Untertitel der Top-10-Karte z. B. „aktueller Bestand aktiver Kundenabos — weicht bewusst von der Incentive-Zählung ab (dort zählen alle im Aktionszeitraum gestarteten Abos dauerhaft)". Dazu eine kurze Erklärung an die Kundin (Text kann aus der Tabelle oben übernommen werden). +2. **Daten-Auffälligkeit klären (separat!):** Es gibt im Team von 454 **7 Kundenabos, die im Juli per Soft-Delete gelöscht wurden, aber weiter `active=1`/Status 2-3 tragen** (Löschung vermutlich über den Admin-Delete [AboController.php:170-178](app/Http/Controllers/Admin/AboController.php#L170-L178), der Status/Flags nicht anfasst). Diese Abos werden durch den SoftDelete-Scope nirgends mehr ausgeführt oder gezählt — der inkonsistente Zustand (aktiv + gelöscht) sollte aber geprüft werden: War die Löschung beabsichtigt? Sollte `destroy()` zusätzlich `active=0`/Status setzen? + +++ Umsetzen wie in der Empfehlung zusätzlich ja es ist richtig die Abos mussten gelöscht werden, da diese neu angelegt worden sind. Hier gab's Zahlungsprobleme grundsätzlich auf active null setzen und auch gleich die Funktion reparieren. + +**Umgesetzt (2026-07-24):** +- UI-Text: Untertitel der Karte lautet jetzt „aktueller Bestand aktiver Kundenabos · weicht bewusst von der Incentive-Zählung ab" mit Erklär-Tooltip. +- Funktion repariert: `UserAbo` hat einen `deleting`-Hook — jeder Softdelete setzt jetzt automatisch `active = false` (gilt für Admin-`destroy()`, Portal-`deletePending()` und alle weiteren Model-Deletes). Test in `AboAdminDeleteTest` („setzt beim Softdelete auch active auf false"). +- Datenbereinigung: Migration `2026_07_24_120000_deactivate_soft_deleted_user_abos` deaktiviert alle bestehenden gelöschten-aber-aktiven Abos (auf dem Testsystem ausgeführt: 7 Datensätze bereinigt; läuft in Produktion beim Deploy). + +## 3. Top 10 Kundenabos (Umsatz): Soll/Ist unklar, Abo-Anzahl fehlt + +**Feedback:** Warum ist Soll mal höher, mal niedriger als Ist? Was heißt „pro Zyklus"? Gewünscht: bereits **angerechnete** Kundenabopunkte des Monats + Anzahl der Abos dahinter (z. B. „Tanja: 1123 Punkte mit 16 Kundenabos"). + +**Befund/Ursache:** +- **Soll** = Punktebestand der *heute* aktiven Kundenabos (Produktpunkte × Menge, ohne gelöschte/stornierte) — „was die Abos pro Lieferzyklus bringen" ([BackofficeDashboardService.php:721-736](app/Services/Backoffice/BackofficeDashboardService.php#L721-L736)). +- **Ist** = im gewählten Monat tatsächlich abgerechnete Kundenabo-Punkte aus `user_sales_volumes` ([Z. 744-756](app/Services/Backoffice/BackofficeDashboardService.php#L744-L756)). +- **Ist > Soll** entsteht, wenn Abos nach ihrer Abrechnung gelöscht/storniert wurden (Beispiel Tanja: 2 im Juli gelöschte Abos wurden Anfang Juli noch abgerechnet → stecken im Ist 1.123, fehlen im Soll 992) oder Mengen/Artikel reduziert wurden. **Ist < Soll**, wenn Abos im Monat neu dazukamen und ihr Liefertermin noch aussteht. Beides ist korrekt, nur unglücklich erklärt. + +**Empfehlung (kleiner Umbau):** +1. **Ist als Hauptwert** (Sortierung nach Ist statt Soll — die Kundin denkt vom abgerechneten Monatsumsatz her), Soll als Zweitwert. +2. **Spalte „Abos"** ergänzen (Anzahl aus `customerAboCounts()`, ist bereits berechnet). +3. Beschriftung: „Soll" → z. B. „Punkte pro Lieferung (heutiger Bestand)", „Ist" → „im {Monat} abgerechnet". +4. Eigene Person aufnehmen (Punkt 1/2a). + +++ umsetzen wie in der Empfehlung. + +**Umgesetzt (2026-07-24):** Sortierung nach Ist (`points_actual`), neue Spalte „Abos"; Beschriftungen: „Abgerechnet (Punkte)" (Hauptwert, fett) und „Bestand/Lieferung" (Zweitwert), Untertitel „sortiert nach abgerechneten Kundenabo-Punkten {Monat}" mit Erklär-Tooltip; „ich" inklusive. Test: „sortiert die Top-10-Umsatzliste nach abgerechneten Ist-Punkten und liefert die Abo-Anzahl mit". + +## 4. Führungskräfte-Statistik: „Zahlen stimmen leider nicht" + +**Feedback:** Teamabos/Team-Kundenabos sollen die **komplette Downline** umfassen. Beispiele: Rosmarie tatsächlich 81 Teamabos + 200 Teamkundenabos (Liste: 35 + 54), Nina 21 + 52 (Liste: 4 + 12). + +**Befund: Kein Rechenfehler — die Liste tut exakt das, was in F9 entschieden wurde:** Der Teilbaum jeder Führungskraft wird an der **nächsten Führungskraft abgeschnitten** ([BackofficeDashboardService.php:627-659](app/Services/Backoffice/BackofficeDashboardService.php#L627-L659), `continue` bei Leader-Kind), damit nichts doppelt zählt und die Summen additiv sind (Kopfzeile „464" = exakte Gesamtsumme). + +Nachgerechnet ohne Abschneiden (DB heute): **Rosa 82 Teamabos + 201 Teamkundenabos** (Kundin: 81/200 — Stand ihres Tests), **Nina exakt 21 + 52** wie von der Kundin angegeben. Die Kundin hat also die komplette Downline gezählt — ihre Erwartung widerspricht der eigenen Entscheidung F9 („an der nächsten Führungskraft abschneiden", siehe `entwicklungskonzept.md`, Entscheidungstabelle). + +**Konsequenz der Umstellung, die die Kundin kennen sollte:** Zählt jede Führungskraft ihre komplette Downline, werden Abos **mehrfach** gezählt (Ninas 73 Abos stecken dann auch komplett in Rosas 283). Die Kopf-Summe „464" und die Spaltensummen sind dann **keine** echten Gesamtwerte mehr. + +**Empfehlung:** +1. Umstellung auf komplette Downline ist technisch klein (das `continue` in `teamIdsPerLeader()` entfernen bzw. Schalter einbauen); Performance unkritisch (eine gemeinsame Sponsor-Map, BFS in memory). +2. **Vorher kurz an die Kundin:** „Dann zählen Abos bei mehreren Führungskräften gleichzeitig — die Gesamtsumme oben rechnen wir dann weiterhin ohne Doppelzählung. Okay?" (Kopfzeile auf Basis der Gesamtmenge, Zeilen mit kompletter Downline.) +3. Optional beide Sichten: Spalten „Team (direkt)" und „Team (gesamt)" — dann bleibt die additive Sicht erhalten. + +++ wir sitzen Option drei, um dass wir beides zeigen und kurz erklären. Das ist relativ komplex und vermutlich wohl nicht verständlich. Von daher würde ich erst mal beides zeigen und dann klären, was dein sinnvoller ist.. + +**Umgesetzt (2026-07-24, Option 3 — beide Sichten):** `leaderAboOverview()` liefert je Führungskraft jetzt zusätzlich `team_abo_count_total`/`team_customer_abo_count_total` (komplette Downline, ohne Abschneiden) neben den bisherigen „direkt"-Werten. Die Tabelle zeigt beide Spaltenpaare — „Teamabos/Team-Kundenabos (direkt)" und „(gesamt)" — mit Tooltips; die **Gesamt-Spalte** rechnet mit der kompletten Downline (entspricht der Zählung der Kundin: eigenes Abo + Kundenabos + Teamabos gesamt + Team-Kundenabos gesamt), sortiert danach. Die **Kopf-Summe** bleibt bewusst überschneidungsfrei („direkt"), eine Fußnote unter der Tabelle erklärt den Unterschied. Test: „liefert je Fuehrungskraft zusaetzlich die Teamzahlen der kompletten Downline". → Nach Livegang mit der Kundin klären, ob beide Sichten bleiben oder eine reicht. + +## 5. Statistik 97.453 ≠ Struktur 92.211 („theoretisch gleich") + +**Befund: Beide Zahlen sind korrekt — die Differenz sind Punkte, die nicht payline-wirksam sind.** + +- Die Statistik-Übersicht summiert je Linie `own_points + external_points` aus `user_sales_volumes` ([BackofficeDashboardService.php:437-464](app/Services/Backoffice/BackofficeDashboardService.php#L437-L464)) — also **alle** Umsatzpunkte der Team-Mitglieder. +- Die Struktur-/Provisionsansicht (PP-Tabelle) summiert je Ebene `month_TP_points + month_shop_points` ([TreeCalcBotOptimized.php:279-300](app/Services/BusinessPlan/TreeCalcBotOptimized.php#L279-L300), [UserSalesVolume::getPointsTPSum()](app/Models/UserSalesVolume.php)) — nur die **Teampunkte (TP)**, die in die Payline der Upline fließen. +- Der Unterschied entsteht in [SalesPointsVolume.php:56-64](app/Services/BusinessPlan/SalesPointsVolume.php#L56-L64): Einträge mit `status_points = 2` zählen **nur als KP (Karrierepunkte des Beraters), nicht als TP**. + +Nachgerechnet für das Team von 454, Juli 2026 (heute): Statistik-Summe **97.769**, TP-Summe **92.569**, Differenz **5.200** = **19 Neupartner-Einschreibungen** (Status 5, je 200/400 Punkte = 5.000) + **1 Gutschrift** (200 Punkte), alle mit `status_points=2`. Die Screenshot-Werte (97.453/92.211, Differenz 5.242) sind derselbe Effekt zum Testzeitpunkt. + +**Empfehlung:** Kein Fix nötig. In der Statistik-Übersicht eine Fußnote/Tooltip ergänzen: „enthält auch Punkte, die nicht in die Payline fließen (z. B. Neupartner-Einschreibungspunkte)" — optional als eigene Aufschlüsselungszeile (Datenbasis vorhanden: `status_points=2`-Summe je Linie). + +++ Umsetzung wie in der Empfehlung. + +**Umgesetzt (2026-07-24):** Neues SQL-Fragment `SalesVolumePoints::nonPaylinePoints()` (status_points = 2) + Kennzahl `non_payline_points` im Overview/Snapshot. Unter der Linien-Tabelle erscheint eine Fußnote: „In den Gesamtpunkten enthalten: X Punkte, die nicht in die Payline fließen (z. B. Neupartner-Einschreibungspunkte) …" (nur wenn > 0; alte Snapshots ohne den Wert zeigen nichts). Test: „summiert Punkte ohne Payline-Wirkung (status_points 2) als eigene Kennzahl". + +## 6. Team-Abo-Seite: „Was bedeuten diese 42.628 Abo-Punkte?" + +**Befund:** Die Spalte „Punkte" in „Abos nach Ebenen" ist der **Soll-Bestand pro Lieferzyklus**: Summe `getTotalPoints()` (Produktpunkte × Menge) aller **aktiven** Abos (Eigen- + Kundenabos) je Ebene — über **alle 10 Ebenen** des Teams ([TeamAboOverviewService.php:282-326](app/Services/Abo/TeamAboOverviewService.php#L282-L326)). Es ist also **nicht** der abgerechnete Umsatz, sondern „was alle Team-Abos pro Zyklus bringen würden". + +Zur Einordnung der Karte darüber: „Abo-Umsatz in der Payline" (Soll 38.375 / Ist 37.912) ist dieselbe Soll-Logik, aber nur für die **Payline** (6 Ebenen), plus Ist = im Monat abgerechnete Abo-Punkte ([PaylineForecastService.php:75-86](app/Services/Abo/PaylineForecastService.php#L75-L86)). 42.628 > 38.375, weil das Team 10 Ebenen hat, die Payline nur 6. + +Der Wunsch „Zahlen, die zum Stichtag 25. im aktuellen Monat abgerechnet werden" ist auf dem Home-Dashboard durch die **Prognose-Kacheln 5./10./20./25.** (B10) bereits abgedeckt. + +**Empfehlung:** +1. Spaltenüberschrift präzisieren: „Punkte pro Lieferzyklus (Bestand)". +2. **Ist-Spalte je Ebene** ergänzen („im Juli abgerechnet") — SQL-Fragment `SalesVolumePoints::aboPoints()` existiert bereits, nur je Ebenen-User-Menge gruppieren. +3. Optional dritte Spalte „davon bis zum 25. erwartet" (Logik aus `forecastDays()` wiederverwendbar). + +++ Umsetzung wie in einer Empfehlung alle drei Punkte entsprechend umsetzen. + +**Umgesetzt (2026-07-24):** Die Ebenen-Tabelle hat jetzt drei Punkte-Spalten: „Punkte pro Lieferung (Bestand)" (bisherige Spalte, präzisiert), „abgerechnet ({Monat})" (`abo_points_billed`, gleiche Zählweise wie der Payline-Ist-Wert) und „bis zum 25. erwartet" (`abo_points_expected` = abgerechnet + offene Ausführungen mit `next_date` bis zum 25.; nur im laufenden Monat, sonst = abgerechnet) — inkl. Summenzeile und Tooltip. Sprachkeys in de/en/es/fr. Test: „liefert je Ebene abgerechnete Abo-Punkte und die Prognose bis zum letzten Liefertag". + +## 7. Payline: Anzahl der Abos ausweisen + +**Feedback:** „Wieviel ABOs gibt es in der Payline? z. B. 205 Teampartnerabos + 302 Teamkundenabos (6 Ebenen)." + +**Befund:** Die Karte zeigt bisher nur „Payline = 6 Ebenen · 504 Berater". Die Zähler sind trivial ermittelbar (gleiche Filter wie `aboTargetPoints()`, [PaylineForecastService.php:164-183](app/Services/Abo/PaylineForecastService.php#L164-L183)). Heute für User 454: 6 Ebenen, 502 Berater, **129 Teampartner-Abos + 340 Team-Kundenabos** (die Beispielzahlen der Kundin waren Schätzungen). + +**Empfehlung:** `aboTurnover()` um `team_abo_count`/`team_customer_abo_count` erweitern und in der Kartenunterzeile anzeigen: „6 Ebenen · 502 Berater · 129 Teamabos + 340 Kundenabos". Aufwand klein, Test in `PaylineForecastServiceTest` ergänzen. + +++ Umsetzung wie in einer Empfehlung + +**Umgesetzt (2026-07-24):** `aboTurnover()` liefert zusätzlich `team_abo_count`/`team_customer_abo_count` (gleiche Filter wie das Soll); die Karten-Unterzeile zeigt jetzt z. B. „Payline = 6 Ebenen · 502 Berater — 129 Teamabos + 340 Kundenabos in der Payline" (Sprachkey `abo.payline_abo_counts`, de/en/es/fr). Test in `PaylineForecastServiceTest` erweitert. + +## 8. Live-Rangliste Montenegro: Nummerierung + +**Feedback (berechtigt):** Gewinner müssen 1, 2, 3, … nummeriert sein; wer mehr Punkte hat, aber die Mindestqualifikation nicht erfüllt, wird **darunter** gereiht (Beispiel Tanja: meiste Punkte, nicht qualifiziert → Platz 5, nicht Platz 1). + +**Befund/Ursache:** Der gespeicherte Rang ist ein **reines Punkte-Ranking über alle Teilnehmer** ([IncentiveTracker.php:320-341](app/Services/Incentive/IncentiveTracker.php#L320-L341), Qualifikation spielt keine Rolle). Die **Anzeige** sortiert aber zuerst nach `is_qualified DESC` ([IncentiveParticipant.php:126-143](app/Models/IncentiveParticipant.php#L126-L143)). Ergebnis: Qualifizierte stehen oben, tragen aber ihre globalen Punkte-Ränge (4, 7, 9), darunter stehen Rang 1, 2, 5 — genau die gemeldete Verwirrung. + +**Zusätzliche Brisanz:** `isWinner` prüft `rank <= max_winners` ([IncentiveParticipant.php:519-526](app/Models/IncentiveParticipant.php#L519-L526)). Mit dem heutigen Punkte-Rang könnte ein qualifizierter Berater mit globalem Punkte-Rang 21+ **fälschlich kein Gewinner** sein, obwohl weniger als 20 qualifiziert sind. Die Sortier-Korrektur repariert also auch die Gewinner-Logik. + +**Empfehlung:** `updateRanking()` auf `ORDER BY is_qualified DESC, total_points DESC` umstellen (Tie-Break unverändert) — dann ist der gespeicherte Rang identisch mit der Anzeigereihenfolge: Qualifizierte zuerst (1..n), Nicht-Qualifizierte dahinter; „springt nach oben", sobald die Qualifikation erreicht ist. **Vorher prüfen:** Wo wird `rank` noch verwendet (Mails/Benachrichtigungen bei Rangänderung, `withRankingActivity()`)? Rangsprünge durch die Umstellung könnten einmalig Benachrichtigungen auslösen — ggf. Benachrichtigung für den Umstellungs-Lauf unterdrücken. Test ergänzen (qualifiziert schlägt Punkte). + +++ Umsetzung wie in einer Empfehlung + +**Umgesetzt (2026-07-24):** `IncentiveTracker::updateRanking()` sortiert jetzt `is_qualified DESC → total_points DESC → Terms-Tie-Break`; der gespeicherte Rang entspricht damit exakt der Anzeigereihenfolge (Qualifizierte 1..n, danach die Nicht-Qualifizierten; wer sich qualifiziert, springt hoch — genau das Kundin-Beispiel „Tanja → Platz 5"). Nebenwirkungen geprüft: `is_qualified` wird vor jedem Ranking-Lauf frisch berechnet, es existiert **kein** Rang-Benachrichtigungsmechanismus (keine Mail-Flut möglich), und `isWinner (rank <= max_winners)` wird durch den Fix erst korrekt (vorher konnte ein Qualifizierter mit Punkte-Rang 21+ fälschlich leer ausgehen). Neuer Test `Incentive/IncentiveRankingTest` (4 Fälle); ein vorbestehend roter Unit-Test (Reflection auf falsche Klasse) wurde mitrepariert. + +## 9. Porto Deutschland: Abo 19,90 vs. Einzelbestellung 16,72 + +**Befund: Es gibt keinen echten Preisunterschied — es ist derselbe Versandsatz, einmal brutto und einmal netto angezeigt.** 16,72 × 1,19 = 19,90. + +- Konfiguration: `shipping_prices.price_comp` (Teampartner-Versand, **brutto**): DE **19,90 €**, AT **30,00 €**, jeweils `tax_rate` 19 %. +- Der **Abo-Checkout** zeigt für steuerpflichtige DE-Teampartner den **Brutto**-Versand: [_order_combined_summary.blade.php:31](resources/views/admin/abo/_order_combined_summary.blade.php#L31), [_order_abo_show.blade.php:93](resources/views/admin/abo/_order_abo_show.blade.php#L93) (`$taxFree ? shippingNet() : shipping()`), [portal/abo/_create_check.blade.php:158](resources/views/portal/abo/_create_check.blade.php#L158) sogar fest brutto. +- Die **Einzelbestellungs-Übersicht** ist eine Netto-Aufstellung und zeigt **netto**: [api_order_list_total.blade.php:57](resources/views/user/shop/sales/api_order_list_total.blade.php#L57) → 19,90 / 1,19 = **16,72 €**. +- **Österreich:** Teampartner mit gültiger USt-ID ist Reverse-Charge-steuerfrei (`user_tax_free`, [UserService.php:103-137](app/Services/UserService.php#L103-L137)) → auch der Abo-Zweig zeigt netto: 30,00 / 1,19 = **25,21 €** — deshalb dort „passt es". + +Die tatsächlich berechneten Gesamtbeträge sind konsistent (netto + MwSt); es ist eine **Anzeige-Inkonsistenz**, kein Abrechnungsfehler. + +**Empfehlung:** Porto-Ausweis vereinheitlichen — im Abo-Checkout in der Netto-Aufstellung den **Netto**-Versand zeigen (bzw. „19,90 € inkl. MwSt" kennzeichnen), damit Abo und Einzelbestellung vergleichbar sind. Nebenbefund: [Yard.php:323](app/Services/Yard.php#L323) berechnet `shipping_tax` falsch (liefert den Nettowert statt des Steueranteils) — mit fixen. + +++ Umsetzung wie in einer Empfehlung, Mit einer Kennzeichnung, denn wichtig ist. Bei MU Preise müssen wir immer den Bruttowert anzeigen. Bei den Beratern im System können wir den Nettowert anzeigen. Das ist leider nun mal so, dass die Berater als selbstständige im netto handeln und alles, was nach außen geht, an Kunden brutto zu behandeln ist. Das müsste einmal geprüft werden, dass das einheitlich ist und dann bitte netto oder inklusive Mehrwertsteuer kennzeichnen. + +**Umgesetzt (2026-07-24) nach der Regel „Berater netto (gekennzeichnet), Endkunden brutto (gekennzeichnet)":** +- Wichtiger Befund: `_order_combined_summary` und `_order_abo_show` werden **auch im Kundenportal** eingebunden. Deshalb neues View-Flag `portal_view` (Default = Berater), gesetzt von den Portal-Aufrufern (`Portal/AboController`, `portal/abo/my_abo`). +- Berater-Ansichten (Abo-Detail Berater/Admin, Einzelbestellungs-Übersicht): Versand **netto** + Kennzeichnung „(netto)" (Key `order.net`, existierte). Die Netto-Summenzeilen passen jetzt rechnerisch zur Porto-Zeile. +- Kundenportal (Abo anlegen/ansehen): Versand **brutto** + „(inkl. MwSt.)" (neuer Key `order.incl_VAT`, de/en/es/fr); bei Reverse-Charge/steuerfrei automatisch netto + „(netto)". +- Nebenbefund gefixt: `Yard::shipping_tax` berechnete den Nettowert statt des Steueranteils (jetzt brutto − netto); alle anderen `shipping_tax`-Verwendungen (HomepartyCart, ShopApiOrderCart, Correction) waren bereits korrekt und blieben unverändert. Tests: `YardShippingTaxTest` (neu) + 2 Rendering-Tests in `AboOneTimeViewTest`. +- **Nachtrag (gleicher Tag):** Drei weitere Versandkosten-Anzeigen ergänzt, die in der ersten Runde fehlten (dort waren die Kundin-Screenshots entstanden): CRM-Warenkorb der Berater [user/order/yard_view_form.blade.php](resources/views/user/order/yard_view_form.blade.php) → „(netto)"; Shop-Warenkorb [web/templates/card.blade.php](resources/views/web/templates/card.blade.php) und Shop-Checkout [web/templates/checkout.blade.php](resources/views/web/templates/checkout.blade.php) → „(inkl. MwSt.)", bei Reverse-Charge/steuerfrei netto + „(netto)" (dort zeigte die Porto-Zeile zuvor sogar brutto über einer Netto-Summenzeile). Nicht angefasst: Homeparty-/Sammelbestellungs- und PDF-/Mail-Ansichten (eigene Belegformate). +- **Für die Kundin:** Es gab nie einen Preisunterschied — DE-Teampartner-Porto ist 19,90 € brutto = 16,72 € netto; jetzt ist überall gekennzeichnet, welcher Wert gezeigt wird. + +## 10. Struktur-Ansicht: Symbole „A" (eigenes Abo) und „(n)" (Kundenabos) + +**Feedback (neue Anforderung):** In der Struktur je Teampartner zwei Kennzeichen: grünes **A** = hat eigenes Abo, rotes **(8)** = Anzahl eigener Kundenabos. + +**Einschätzung:** Machbar, Datenbasis vorhanden (aktives `me`-Abo je `user_id`, Kundenabo-Zählung je `member_id` — gleiche Queries wie in der Statistik). Umsetzungsort: Struktur-/Tree-Rendering ([TeamController.php](app/Http/Controllers/User/TeamController.php), `TreeHtmlRenderer`). Wichtig: Zählungen **gebatcht** für alle sichtbaren Knoten laden (zwei Group-by-Queries), nicht pro Knoten. Aufwand mittel (inkl. Badge-Styling + Test). Vorschlag: gleiche Zählweise wie Top-10 (aktiver Bestand, ohne gelöschte) — dann sind Struktur und Statistik konsistent. + +++ So umsetzen + +**Umgesetzt (2026-07-24):** Badges im `TreeHtmlRenderer` (Struktur-Ansicht, `TeamController::structure()`): grünes „A"-Badge bei eigenem aktivem Abo, rotes „(n)"-Badge mit der Anzahl aktiver eigener Kundenabos (nur bei n > 0), direkt neben dem Karriere-Level. Zählweise identisch zur Statistik (`activeAbo`, ohne gelöschte); Daten werden für den ganzen Baum mit zwei gebatchten Queries geladen (keine Query pro Knoten). Tooltips über Sprachkeys `team.badge_own_abo`/`team.badge_customer_abos` (de/en/es/fr). Neuer Test `TeamStructureAboBadgesTest` (5 Fälle). + +--- + +## Rückfragen an die Kundin — Stand nach Umsetzung + +1. ~~Führungskräfte direkt vs. gesamt~~ → **entschieden: beide Sichten** (Option 3, umgesetzt). Nach Livegang klären, ob beide Spaltenpaare bleiben. +2. ~~Top-10-Umsatz-Sortierung~~ → **entschieden: nach Ist** (umgesetzt). +3. ~~Gelöschte aktive Abos~~ → **beantwortet:** Löschungen waren beabsichtigt (Abos wurden wegen Zahlungsproblemen neu angelegt). Fix: Softdelete setzt jetzt `active=0` (Model-Hook) + Datenbereinigung per Migration. +4. **Neu/offen (F12 aus dem ursprünglichen Konzept, weiterhin unbeantwortet):** Zählen eigene Abos/Kundenabos in der Payline-Prognose mit? Aktuell ja (`PaylineForecastService::INCLUDE_OWN_ABOS = true`). + +## Anhang: Verifikationsdaten (DB-Stand 2026-07-24, Team von User 454) + +| Beraterin | Top-10 (aktiver Bestand) | davon-Rechnung Montenegro | +|---|---|---| +| Tanja Longerich (909) | 13 | 17 = 13 + 1 Eigenabo + 2 gelöschte + 1 storniertes | +| Rosa M. Assenbaum (456) | 13 | 15 = 13 + 1 Eigenabo + 2 gelöschte − 1 vor Zeitraum aktiviert | +| Monika Kunz (1218) | 13 | 16 = 13 + 1 Eigenabo + 2 gelöschte | +| Melanie Hoffmann-G. (1143) | 13 | 15 = 13 + 1 Eigenabo + 1 gelöschtes | +| Claudia Lattner (454) | 7 (fehlt in Liste, Punkt 1) | 8 = 7 + 1 Eigenabo | + +- Statistik-Linien-Summe heute: 97.769; TP-Summe (Struktur): 92.569; Differenz 5.200 = 19 Neupartner-Einschreibungen (5.000 P.) + 1 Gutschrift (200 P.), alle `status_points=2`. +- Führungskräfte komplette Downline heute: Rosa 82 Teamabos / 201 Teamkundenabos (Kundin: 81/200), Nina 21 / 52 (exakt wie Kundin). +- Payline 454 heute: 6 Ebenen, 502 Berater, 129 Teamabos, 340 Teamkundenabos. +- Montenegro-Incentive: `incentives` id 2, Qualifikation 01.04.–31.07.2026, min. 4 Partner / 6 Abos, max. 20 Gewinner. + +## Anhang: Geänderte Dateien (Umsetzung 2026-07-24) + +**Services/Models:** `BackofficeDashboardService` (ich-Aufnahme, Ist-Sortierung, direkt+gesamt-Teamzahlen, `nonPaylinePointsSum`), `SalesVolumePoints::nonPaylinePoints()`, `TeamAboOverviewService` (`abo_points_billed`/`abo_points_expected`), `PaylineForecastService` (Abo-Zähler), `UserAbo` (deleting-Hook), `IncentiveTracker::updateRanking()`, `Yard` (shipping_tax), `TreeHtmlRenderer` (Abo-Badges), `Portal/AboController` (`portal_view`). + +**Views:** `user/backoffice/statistics/index.blade.php`, `user/team/abos.blade.php`, `admin/abo/_order_combined_summary.blade.php`, `admin/abo/_order_abo_show.blade.php`, `portal/abo/_create_check.blade.php`, `portal/abo/my_abo.blade.php`, `user/shop/sales/api_order_list_total.blade.php`. + +**Sprachdateien:** `abo.php` (payline_abo_counts, abo_points_per_cycle/billed/expected + hint), `order.php` (incl_VAT), `team.php` (badge_own_abo, badge_customer_abos) — jeweils de/en/es/fr. + +**Migration:** `2026_07_24_120000_deactivate_soft_deleted_user_abos` (Datenbereinigung gelöschte-aber-aktive Abos). + +**Tests:** neu `Incentive/IncentiveRankingTest`, `TeamStructureAboBadgesTest`, `YardShippingTaxTest`; erweitert `BackofficeStatisticsListsTest`, `TeamAboOverviewTest`, `PaylineForecastServiceTest`, `AboAdminDeleteTest`, `AboOneTimeViewTest`, `Unit/Incentive/IncentiveTrackerTest`. diff --git a/dev/2026-07-22/neuerungen-fuer-berater.md b/dev/2026-07-22/neuerungen-fuer-berater.md new file mode 100644 index 00000000..92210d8e --- /dev/null +++ b/dev/2026-07-22/neuerungen-fuer-berater.md @@ -0,0 +1,116 @@ +# Was ist neu? – Übersicht der Neuerungen (Juli 2026) + +Diese Übersicht erklärt alle Neuerungen, die auf **my.mivita.care** freigeschaltet wurden – mit direkten Links, damit du sofort siehst, wo du klicken musst. + +**Kurzüberblick:** + +| Neuerung | Wo? | Für wen? | +| --- | --- | --- | +| Abo pausieren | [Mein Abo](https://my.mivita.care/user/abos/me) | Alle Berater mit Abo | +| Neues Abo nach Kündigung möglich | [Mein Abo](https://my.mivita.care/user/abos/me) | Alle Berater | +| Provision „Stand heute" | [Dashboard](https://my.mivita.care/home) | Alle Berater | +| Payline-Prognose (Liefertage) | [Dashboard](https://my.mivita.care/home) | Alle Berater | +| Payline-Abo-Umsatz Soll/Ist | [Team-Abos](https://my.mivita.care/user/abos/team/show) | Alle Berater | +| Statistik: aktuelle Abo-Bestände, Neupartner-Liste, Top-10, Führungskräfte-Zahlen u. v. m. | [Statistik](https://my.mivita.care/user/backoffice/statistics) | VIP-Berater | + +--- + +## 1. Abo pausieren – einen Monat aussetzen + +**Wo:** [Mein Abo](https://my.mivita.care/user/abos/me) → Abo öffnen → Button **„Abo bearbeiten"** + +Du kannst dein Abo jetzt selbst um **einen Monat pausieren**. Die nächste Lieferung wird dabei automatisch um einen Monat nach hinten geschoben – dein Abo bleibt bestehen und läuft danach ganz normal weiter. + +So funktioniert es: + +1. Öffne [Mein Abo](https://my.mivita.care/user/abos/me) und klicke auf **„Abo bearbeiten"**. +2. Im Fenster findest du (unter dem Liefertag) den Bereich **Pause** mit dem Button **„Abo pausieren"**. +3. Nach dem Bestätigen siehst du sofort das neue Datum der nächsten Lieferung. + +Die Spielregeln: + +- Eine Pause ist möglich, sobald dein Abo **6 bezahlte Lieferungen** erhalten hat (Mindestlaufzeit). +- Du kannst **einmal alle 6 Monate** pausieren. Ist die Pause gerade nicht möglich, zeigt dir das Fenster an, **ab wann** du wieder pausieren kannst. +- Kurz vor dem nächsten Liefertermin (wenn die Bestellung bereits in Vorbereitung ist) ist keine Pause mehr möglich – dann greift sie ab der übernächsten Lieferung. + +Das Bearbeiten-Fenster wurde dabei auch aufgeräumt: oben der **Liefertag**, darunter die **Pause**, und ganz unten – bewusst dezent – die Kündigung. + +## 2. Kündigung blockiert kein neues Abo mehr + +Bisher konnte es passieren, dass nach einer Abo-Kündigung **kein neues Abo** angelegt werden konnte, weil das alte, gekündigte Abo das System blockiert hat. Das ist behoben: Ein gekündigtes oder ausgelaufenes Abo steht einem **Neustart** nicht mehr im Weg. Wer sein Abo beendet hat, kann also jederzeit wieder ein neues abschließen. + +## 3. Dashboard: Provision „Stand heute" 💶 + +**Wo:** [Dashboard (Startseite)](https://my.mivita.care/home) → Karte „Monatsstatistik" + +Im Umsatz-Bereich der Monatsstatistik gibt es eine neue Kachel: **deine Provision zum heutigen Stand** – in Euro, für jeden Berater sichtbar. Sie enthält alle Bestandteile: Payline-Provision, Shop-Provision und Wachstumsbonus. + +Wichtig zu wissen: + +- Im **laufenden Monat** trägt die Kachel ein **„Live"**-Zeichen: Der Wert ist eine Momentaufnahme und wird ca. alle **6 Stunden** aktualisiert. Er ist **vorläufig** – verbindlich wird die Provision erst mit dem Monatsabschluss. +- Bei **abgeschlossenen Monaten** siehst du automatisch die endgültig abgerechnete Provision (ohne „Live"-Zeichen). + +## 4. Dashboard: Payline-Prognose zu den Liefertagen 📈 + +**Wo:** [Dashboard (Startseite)](https://my.mivita.care/home) → Karte „Monatsstatistik", unterhalb der Kacheln + +Für den laufenden Monat siehst du jetzt eine **Vorschau auf die Abo-Liefertage** (5., 10., 20., 25.): Für jeden noch anstehenden Liefertag wird angezeigt, wie viele Abo-Punkte aus deiner Payline dazukommen und wie hoch dein Punktestand **voraussichtlich insgesamt** danach ist. Bereits vergangene Liefertage sind als „erledigt" markiert. + +So erkennst du früh im Monat, wo du am Monatsende ungefähr landen wirst – z. B. ob die nächste Qualifikationsstufe erreichbar ist. + +## 5. Team-Abos: Payline-Abo-Umsatz Soll und Ist + +**Wo:** [Team-Abos](https://my.mivita.care/user/abos/team/show) + +Auf der Team-Abo-Seite gibt es eine neue Karte mit dem **Abo-Umsatz deiner Payline** – also der Ebenen deines Teams, die für deine Provision zählen: + +- **Soll:** die Punkte, die die aktiven Abos deiner Payline in diesem Monat planmäßig bringen. +- **Ist:** die Punkte, die davon **bereits bestellt und bezahlt** wurden. + +Darunter steht, wie viele Ebenen und Teampartner aktuell zu deiner Payline gehören. + +--- + +## Neuerungen in der Statistik (nur für VIP-Berater) + +**Wo:** [Statistik](https://my.mivita.care/user/backoffice/statistics) + +### 6. Abo-Bestände immer aktuell + +Die Abo-Zahlen (Teamabos, Kundenabos, Führungskräfte-Abos, Top-Kundenabos) zeigen jetzt **immer den aktuellen Bestand** – auch wenn du einen bereits abgeschlossenen Monat auswählst. Vorher wurden bei alten Monaten eingefrorene Zahlen angezeigt; jetzt siehst du den Bestand von heute (erkennbar am Hinweis „aktueller Stand"). + +### 7. Provisions-Karte in der Übersicht + +Neben „Neupartner" und „1000-Punkte-Shops" gibt es oben eine dritte Karte: **deine Provision** für den gewählten Monat, aufgeschlüsselt nach **Payline, Shop und Wachstumsbonus**. Im laufenden Monat als Live-Wert (siehe Punkt 3), bei alten Monaten der abgerechnete Endstand. + +### 8. Neupartner-Liste mit Sponsor und Kundenpunkten + +Klickst du auf die **Neupartner-Zahl**, öffnet sich jetzt eine Liste der neuen Partner des Monats – inklusive **Sponsor** (wer hat den Partner geworben) und dessen **Kundenpunkten**. Die Liste lässt sich wie gewohnt als **CSV-Datei** herunterladen. + +### 9. „Neu"-Zahlen sind jetzt klickbar + +Überall, wo eine grüne **„+N"-Markierung** neue Abos oder neue Partner anzeigt (auch auf dem [Dashboard](https://my.mivita.care/home)), kannst du jetzt **direkt draufklicken** und landest in der passenden Detail-Liste – gefiltert auf **nur die neuen** Einträge des Monats. + +### 10. 1000-Punkte-Shops: dein eigener Shop zählt mit + +Bei der Kennzahl „1000-Punkte-Shops" wird jetzt auch **dein eigener Shop** mitgezählt, wenn er die 1000 Punkte erreicht hat. In der Detail-Liste ist deine eigene Zeile mit **„(ich)"** markiert. + +### 11. Top-10-Listen: Kundenabos im eigenen Team + +Zwei neue Top-10-Übersichten zeigen, welche Teampartner die meisten **Kundenabos** haben: + +- **Top 10 nach Anzahl** der Kundenabos. +- **Top 10 nach Umsatz** – mit **Soll** (was die Abos planmäßig bringen) und **Ist** (was bereits bestellt wurde). + +Gezählt wird jeweils der aktuelle Abo-Bestand im eigenen Team. + +### 12. Führungskräfte-Übersicht mit echten Teamzahlen + +Die Führungskräfte-Tabelle zeigt pro Führungskraft jetzt: **eigene Abos**, **Kundenabos**, **Teamabos**, **Team-Kundenabos** und die **Gesamtsumme**. Dabei zählt jede Führungskraft nur ihr eigenes Team **bis zur nächsten Führungskraft** – nichts wird doppelt gezählt, die Summen gehen sauber auf. + +--- + +## Gut zu wissen + +- **„Live"-Werte** (Payline-Punkte, Prognose, Provision im laufenden Monat) werden ca. alle **6 Stunden** neu berechnet – dazwischen kann der Wert also ein paar Stunden alt sein. +- Die [Statistik-Seite](https://my.mivita.care/user/backoffice/statistics) ist weiterhin **VIP-Beratern** vorbehalten; Dashboard, Abo-Pause und Team-Abo-Seite gelten für **alle Berater**. diff --git a/dev/2026-07-22/todos.md b/dev/2026-07-22/todos.md new file mode 100644 index 00000000..0a82a715 --- /dev/null +++ b/dev/2026-07-22/todos.md @@ -0,0 +1,89 @@ +ABOS + +- Wenn der Kunde pausieren möchte, kann er dies nach 6 Monaten tun und für maximal 1 x in 6 Monaten pausieren, +Bei den Abo Einstellungen brauchen wir hier eine Pause Funktion d.h. hier kann der User dann einen entsprechenden ein Monat Pause drücken und die Ausführung verschiebt sich um einen Monat + +- Wenn der Kunde stornieren möchte, kann er das nach der 6. Lieferung monatlich kündigen – entweder über den Berater oder den Kunden-LogIn + ist glaube ich schon weitesgehend implementiert bitte noch mal genau prüfen hier machen wir einfach einen Button Abo. Jetzt kündigen dann noch mal eine Abfrage wirklich kündigen und dann wird das Abo entsprechend entfernt. Softdelete + + + Statisitken + + +Dashboard: + Hier fehlt auch noch: Provision aktuell Stand heute +Hier bin ich mir nicht sicher, ob die Zahlen aktuell sind, bei den Abos sieht man nicht die aktuellen Zahlen, wenn man den Monat verändert. + + + Neupartner im Team +Wenn man hier raufklickt, sollte in der Liste auch der Sponsor aufscheinen und ebenfalls die tagesaktuellen Kundenpunkte + +1000- Punkte Shop +Muss ich selbst auch Teil der Statistik sein, wenn jem selbst den 1000 Punkte-Shop erreicht hat, sollte diese Person selbst auch in der Liste aufgeführt werden und nicht nur sein Team + + + + +Kundenabos insgesamt (Anzahl) +Wir benötigen ebenfalls eine Auflistung der TOP 10 von den Kundenabos insgesamt. (unabhängig vom Incentive) +zB Platz 1: Monika Kunz 15 Kundenabos, Platz 2: Tanja Longerich 14 KUndenabos und so absteigend + +Kundenabos insgesamt (Umsatz) +Wir benötigen ebenfalls eine Auflistung der TOP 10 von den Kundenabos insgesamt vom Umsatz. (unabhängig vom Incentive) +zB Platz 1: Tanja Longerich: 1200 Punkte (Kundenumsatz mit Kundenabos), Platz 2: Peggy Groß 800 Punkte (Kundenumsatz mit Kundenabos) + +Führungskräfte- Statisik – teils erledigt +Mittlerweile sieht man die Führugnskräfte ab Vertriebsleiter: wer hat ein eigenes Abo, wer hat weiviel Kundenabos, diese Liste gehört noch ergänzg mit: wieviel Teamabos hat diese Person und wieviele Kundenabos vom Team hat diese Person, und am Ende eine Summe wo alle 4 Kennzahlen zusammengezählt werden + +ABO-Statistik +Wurde einiges erweitert – man kann jetzt sehen wieviel Umsatz mit Abos pro Ebene da sind und wiveviel Gesamtumsatz im Abo + +Interessant und wichtig wäre hier noch: +Abo-Gesamtumsatz in der Payline + + Auf der Übersichtsliste + +Hier auch noch aktuelle Provision als Kennzahl in der Übersichtsliste +Wenn man auf die neuen Abos klickt, sollte man sehen wer diese sind + + +Auf der Übersichtsliste (HOME) benötigen wir noch folgende Kennzahlen +- Voraussichtliche Gesamtumsatz in der Payline am 5. (hier wird der aktuelle Kundenumsatz + Gesamtumsatz der Payline genommen und addiert mit den Abo-Umsätzen Payline Stand heute, die am 5. Dazukommen.) + +- Voraussichtliche Gesamtumsatz in der Payline am 10. (hier wird der aktuelle Kundenumsatz + Gesamtumsatz der Payline genommen und addiert mit den Abo-Umsätzen Stand heute, die am 10. Dazukommen.) + +- Voraussichtliche Gesamtumsatz in der Payline am 20. ((hier wird der aktuelle Kundenumsatz + Gesamtumsatz der Payline genommen und addiert mit den Abo-Umsätzen Stand heute, die am 20. Dazukommen.) + +- Voraussichtliche Gesamtumsatz in der Payline am 25. ((hier wird der aktuelle Kundenumsatz + Gesamtumsatz der Payline genommen und addiert mit den Abo-Umsätzen Stand heute, die am 25. Dazukommen.) + +Das hätte den Vorteil, dass wir Anfang Monat schon wissen, wieviel Umsatz wir in der Payline am 5./10./20./25. Haben werden. Diese Zahl wird aber immer tagesaktuell mit dem aktuellen Kundenumsatz (Abo/nicht Abo, persönliche Bestellungen, neupartnereinschreibungspunkte, etc) und den sonstigen Paylinepunkten (Umsatz ohne Abos) addiert + + +Führungskräfte-Statistik +Pro Führungskraft braucht es folgende Kennzahl +Eigenes Abo, Kundenabo, Teamabos, Kundenabos im Team, SUMME Gesamtabos im Team (eigenes Abo + Kundenabos +Teamabos +Kundenabos im Team) + +https://my.mivita.test/user/backoffice/statistics +Abos bei den führungskrägfen: + +Hier pro Führungskraft auch noch + +Wieviel Abos im Team +Wieviel Kundenabos im Team + +Abos gesamt (eigenes Abo + eigene Kundenabos + teamabos + Kundenabos im team) + + +--- + +## Offene Fragen (Claude, 2026-07-22) — BEANTWORTET + +**Update:** Alle Fragen wurden am 2026-07-22 beantwortet und ins Konzept eingearbeitet — siehe `entwicklungskonzept.md`, Abschnitt „Entscheidungen". Einzig F12 (eigene Abos in der Payline-Prognose) wird als dokumentierter Vorschlag umgesetzt und nach der Umsetzung erneut vorgelegt. Ursprüngliche Fragen: + +1. **Abo-Pause:** Zählen für „nach 6 Monaten" 6 bezahlte Lieferungen (wie bei der Kündigung) oder 6 Kalendermonate? Gilt „1× in 6 Monaten" rollierend? +2. **Kündigung „Softdelete":** Empfehlung: Abo auf Status „gekündigt" setzen statt löschen (verschwindet aus aktiven Listen, bleibt für Statistik/Abrechnung erhalten) — okay so? Zudem muss das Setting `abo-min-duration` in Produktion auf 6 stehen (hat aktuell keinen Default). +3. **Provision „Stand heute":** Welche Bestandteile (nur Payline-Provision oder auch Shop/Wachstum)? Für alle Berater sichtbar oder nur VIP? Aktualisierung alle ~6 h ausreichend? +4. **Abo-Zahlen bei Monatswechsel:** Sollen Abo-Bestandszahlen bei vergangenen Monaten den heutigen Stand zeigen (Empfehlung), während Punkte/Umsätze eingefroren bleiben? +5. **Top-10 Kundenabos:** Eigenes Team oder unternehmensweit? Bestand aktiver Abos? Umsatz = Abo-Punkte-Bestand oder abgerechneter Monatsumsatz? +6. **Führungskräfte-Statistik:** Wenn eine Führungskraft unter einer anderen hängt — zählt deren Team beim Oberen mit (Überlappung) oder wird an der nächsten Führungskraft abgeschnitten? +7. **Payline-Prognose 5./10./20./25.:** Kumuliert (Prognose zum 25. enthält 5./10./20.)? Zählen auch eigene Abos/Kundenabos des Beraters dazu oder nur die Payline-Downline? \ No newline at end of file diff --git a/dev/log-errors/errors.md b/dev/log-errors/errors.md new file mode 100644 index 00000000..5f0bdad3 --- /dev/null +++ b/dev/log-errors/errors.md @@ -0,0 +1,43 @@ +#mivita Exception: https://in.mivita.care/portal/my-subscriptions +Route [user_edit] not defined. (View: /home/ploi/mivita.care/resources/views/admin/abo/_detail.blade.php) (View: /home/ploi/mivita.care/resources/views/admin/abo/_detail.blade.php) +in /home/ploi/mivita.care/vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php (line 517) + if (! is_null($this->missingNamedRouteResolver) && ! is_null($url = call_user_func($this->missingNamedRouteResolver, $name, $parameters, $absolute))) { return $url; } throw new RouteNotFoundException("Route [{$name}] not defined."); } /** * Get the URL for a given route instance. * + + + #mivita Exception: https://mivita.care +SOAP-ERROR: Parsing WSDL: Couldn't load from 'https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl' : failed to load external entity "https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl" +in /home/ploi/mivita.care/app/Http/Controllers/Api/KasController.php (line 75) + $this->checkSession('auth'); try { $SoapLogon = new SoapClient('https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl', [ 'cache_wsdl' => WSDL_CACHE_NONE, 'exceptions' => false ]); $this->CredentialToken = $SoapLogon->KasAuth(json_encode(array( 'KasUser' => $this->kas_user, + + + +#mivita Exception: https://mivita.shop/checkout +Undefined variable $shopping_user (View: /home/ploi/mivita.care/resources/views/web/templates/checkout.blade.php) +in /home/ploi/mivita.care/storage/framework/views/d620a70b10aed9766a5ae675e5aaa600.php (line 384) +
    route('checkout.checkout_card_final'), 'class' => 'row clearfix', 'id'=>'checkout_card_final']); ?>is_for); ?>is_from); ?>homeparty_id); ?> + + + +#mivita Exception: https://my.mivita.care/modal/load +Class name must be a valid object or a string (View: /home/ploi/mivita.care/resources/views/admin/payment/modal_credit_status.blade.php) +in /home/ploi/mivita.care/storage/framework/views/229b6b9c7e2b030b1a72955870e1c7d3.php (line 30) +
    status, array('data-live-search'=>'false', 'class'=>'selectpicker') )); ?>
    + + +#mivita Exception: https://my.mivita.care/storage/file/4654/credit_detail/favicon.ico +App\Repositories\CreditRepository::__construct(): Argument #1 ($model) must be of type App\User, null given, called in /home/ploi/mivita.care/app/Http/Controllers/FileController.php on line 248 +in /home/ploi/mivita.care/app/Repositories/CreditRepository.php (line 21) +class CreditRepository extends BaseRepository{ private $user_credit; public function __construct(User $model) { $this->model = $model; } public function create($request = []) + + +#mivita Exception: https://my.mivita.care/storage/file/4654/credit_detail/pdf +App\Repositories\CreditRepository::__construct(): Argument #1 ($model) must be of type App\User, null given, called in /home/ploi/mivita.care/app/Http/Controllers/FileController.php on line 248 +in /home/ploi/mivita.care/app/Repositories/CreditRepository.php (line 21) +class CreditRepository extends BaseRepository{ private $user_credit; public function __construct(User $model) { $this->model = $model; } public function create($request = []) + + +#mivita Exception: https://my.mivita.care/user/team/members/datatable-optimized?_=1783533898138&columns%5B0%5D%5Bdata%5D=id&columns%5B0%5D%5Bname%5D=id&columns%5B0%5D%5Bsearchable%5D=false&columns%5B0%5D%5Borderable%5D=false&columns%5B0%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B0%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B1%5D%5Bdata%5D=deep&columns%5B1%5D%5Bname%5D=deep&columns%5B1%5D%5Bsearchable%5D=false&columns%5B1%5D%5Borderable%5D=true&columns%5B1%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B1%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B2%5D%5Bdata%5D=user_level&columns%5B2%5D%5Bname%5D=user_level&columns%5B2%5D%5Bsearchable%5D=false&columns%5B2%5D%5Borderable%5D=false&columns%5B2%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B2%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B3%5D%5Bdata%5D=is_qual_kp&columns%5B3%5D%5Bname%5D=is_qual_kp&columns%5B3%5D%5Bsearchable%5D=false&columns%5B3%5D%5Borderable%5D=false&columns%5B3%5D%5Bsearch%5D%5Bvalue%5D=&columns%5B3%5D%5Bsearch%5D%5Bregex%5D=false&columns%5B4%5D%5Bdata% +App\Services\BusinessPlan\TreeHelperOptimized::generateQualKPBadgeForUser(): Argument #1 ($user) must be of type App\User, null given, called in /home/ploi/mivita.care/app/Http/Controllers/User/TeamController.php on line 322 + +in /home/ploi/mivita.care/app/Services/BusinessPlan/TreeHelperOptimized.php (line 36) + } /** * Generiert QualKP Badge für User */ public static function generateQualKPBadgeForUser(User $user, int $month, int $year): string { if (!$user->user_level) { return '-'; } diff --git a/phpunit.xml b/phpunit.xml index 1b8aa5f2..352d8c85 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -36,6 +36,7 @@ + diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 7ee6bb7c..260ea5d7 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -43,6 +43,24 @@ return [ 'error_change_locked' => 'Änderungen sind nicht mehr möglich. Die nächste Ausführung ist in :days Tagen. Änderungen müssen mindestens :lock Tage vorher erfolgen.', 'error_abo_interval_too_soon' => 'Der gewählte Liefertag liegt nur :days Tage entfernt. Bitte wähle einen Liefertag, der mindestens :lock Tage in der Zukunft liegt.', 'error_cancel_locked' => 'Eine Kündigung ist nicht mehr möglich. Die nächste Ausführung ist in :days Tagen. Kündigungen müssen mindestens :lock Tage vorher erfolgen.', + 'abo_copy_pause' => 'Verschiebt die nächste Ausführung um einen Monat nach hinten. Maximal 1x alle :months Monate möglich.', + 'payline_abo_turnover' => 'Abo-Umsatz in der Payline', + 'payline_abo_turnover_hint' => 'Soll = Abo-Punkte pro Zyklus aller aktiven Abos deiner Payline (Berater- und Kundenabos). Ist = im laufenden Monat bereits abgerechnete Abo-Punkte der Payline.', + 'payline_target_label' => 'Soll (Punkte pro Zyklus)', + 'payline_actual_label' => 'Ist (im Monat abgerechnet)', + 'payline_hint' => 'Payline = :lines Ebenen · :members Berater', + 'payline_abo_counts' => ':team_abos Teamabos + :customer_abos Kundenabos in der Payline', + 'payline_none' => 'Noch keine Payline vorhanden (kein Provisionslevel erreicht).', + 'abo_points_per_cycle' => 'Punkte pro Lieferung (Bestand)', + 'abo_points_billed' => 'abgerechnet (:month)', + 'abo_points_expected' => 'bis zum :day. erwartet', + 'abo_points_expected_hint' => 'Bereits abgerechnete Abo-Punkte plus die Punkte aller Abos dieser Ebene, deren Lieferung bis zum :day. des Monats noch ansteht.', + 'pause_abo' => '1 Monat pausieren', + 'confirm_pause' => 'Möchtest du das Abo wirklich um einen Monat pausieren? Die nächste Lieferung verschiebt sich auf den Folgemonat.', + 'pause_success' => 'Dein Abo wurde um einen Monat pausiert. Die nächste Ausführung ist am :date.', + 'error_pause_interval' => 'Das Abo kann maximal 1x alle :months Monate pausiert werden. Die nächste Pause ist ab dem :date möglich.', + 'error_pause_not_active' => 'Nur aktive Abos können pausiert werden.', + 'info_pause_available_from' => 'Eine Pause ist maximal 1x alle :months Monate möglich — wieder ab dem :date.', 'error_pause_locked' => 'Das Abo kann nicht mehr pausiert werden. Die nächste Ausführung ist in :days Tagen. Pausieren muss mindestens :lock Tage vorher erfolgen.', 'error_next_date' => 'Das Datum für die nächste Ausführung nicht korrekt', 'checkout_mail_abo_hl' => 'Dein Abo / regelmäßige Lieferung.', diff --git a/resources/lang/de/abo_history.php b/resources/lang/de/abo_history.php index 841256fe..02e6f354 100644 --- a/resources/lang/de/abo_history.php +++ b/resources/lang/de/abo_history.php @@ -36,6 +36,12 @@ return [ 'desc_rollback' => 'Auf Ursprung zurückgesetzt', // Channels + 'status_history_title' => 'Status-Änderungen (Pause & Kündigung)', + 'status_paused' => 'Pausiert', + 'status_cancelled' => 'Gekündigt', + 'status_deactivated' => 'Deaktiviert', + 'status_reactivated' => 'Reaktiviert', + 'status_shift' => 'Ausführung von :from auf :to verschoben', 'channel_admin' => 'Admin', 'channel_user_me' => 'Berater', 'channel_user_ot' => 'Berater (Kunde)', diff --git a/resources/lang/de/home.php b/resources/lang/de/home.php index a8ce988b..5a1fa24d 100644 --- a/resources/lang/de/home.php +++ b/resources/lang/de/home.php @@ -48,6 +48,12 @@ return [ 'until' => 'bis zum', 'welcome_back' => 'Willkommen zurück', 'your_shop' => 'Dein Shop', + 'commission_today' => 'Provision (Stand heute)', + 'payline_forecast_title' => 'Payline-Prognose zum 5. / 10. / 20. / 25.', + 'payline_forecast_hint' => 'Basis: aktueller Kundenumsatz + Teamumsatz (Payline). Dazu kommen kumuliert die bis zum jeweiligen Termin noch anstehenden Abo-Punkte deiner Payline (inkl. deiner eigenen Abos und Kundenabos). Prognose: Pausen, Kündigungen oder fehlgeschlagene Zahlungen können den Wert verändern.', + 'forecast_day' => 'Prognose zum :date', + 'forecast_upcoming' => 'Abo-Punkte offen', + 'forecast_done' => 'Termin liegt bereits im Ist-Umsatz', 'monthly_statistics' => 'Monatsstatistik', 'customer_turnover_points' => 'Kunden-Umsatz Punkte', 'team_turnover_points' => 'Team-Umsatz Punkte', diff --git a/resources/lang/de/msg.php b/resources/lang/de/msg.php index 41a29c68..068f17b8 100644 --- a/resources/lang/de/msg.php +++ b/resources/lang/de/msg.php @@ -25,6 +25,8 @@ return [ 'homeparty_delete' => 'Auszeitparty gelöscht', 'VATID_could_not_be_validated' => 'Die UST-ID konnte nicht validiert werden, Eingabe bitte prüfen', 'VATID_successfully_entered' => 'UST-ID erfolgreich eingetragen', + 'VATID_german_not_allowed' => 'Deutsche USt-IDs können hier nicht verwendet werden. Das Reverse-Charge-Verfahren gilt nur für ausländische EU-USt-IDs.', + 'VATID_no_longer_valid_taxes_applied' => 'Deine USt-ID ist nicht mehr gültig. Die Bestellung wird daher mit Umsatzsteuer berechnet.', 'reverse_charge_procedure_and_VATID_deleted' => 'Reverse Charge Verfahren und UST-ID gelöscht', 'no_id_card_deposited_please_upload_first' => 'Kein Ausweis hinterlegt, bitte erst hochladen', 'no_trade_licence_deposited_please_upload_first' => 'Kein Gewerbeschein hinterlegt, bitte erst hochladen', @@ -38,6 +40,13 @@ return [ 'account_released' => 'Account freigeschaltet', 'cart_product_not_allowed_for_order_type' => 'Der Warenkorb enthält Artikel, die für diese Bestellart nicht vorgesehen sind. Bitte leere den Warenkorb und wähle nur Produkte, die zu dieser Bestellung passen.', 'cart_max_weight_reached' => 'Das maximale Versandgewicht ist erreicht. Es können keine weiteren Produkte hinzugefügt werden.', + 'gdpr_delete_success' => 'DSGVO-Löschung ausgeführt: Alle personenbezogenen Daten wurden unwiderruflich anonymisiert.', + 'gdpr_delete_customer_success' => 'DSGVO-Löschung ausgeführt: Alle personenbezogenen Daten des Kunden wurden unwiderruflich anonymisiert.', + 'gdpr_delete_customer_button' => 'Kunde DSGVO-löschen', + 'gdpr_confirm_checkbox_missing' => 'Bitte bestätige die DSGVO-Löschung über die Checkbox.', + 'gdpr_error_is_admin' => 'DSGVO-Löschung nicht möglich: Der Account hat Admin-Rechte. Bitte entziehe zuerst die Admin-Rechte.', + 'gdpr_error_active_abo' => 'DSGVO-Löschung nicht möglich: Es besteht noch ein aktives Abo. Bitte kündige das Abo zuerst.', + 'gdpr_error_customer_is_member' => 'DSGVO-Löschung nicht möglich: Dieser Kundendatensatz gehört zu einem Berater-Account. Bitte nutze die DSGVO-Löschung in der User-Verwaltung.', ]; /* {{ __('msg.') }} diff --git a/resources/lang/de/order.php b/resources/lang/de/order.php index 3a5a6c1c..f87c92d6 100644 --- a/resources/lang/de/order.php +++ b/resources/lang/de/order.php @@ -44,6 +44,7 @@ return [ 'gross' => 'brutto', 'gross_price' => 'Preis brutto', 'incentives' => 'Incentives', + 'incl_VAT' => 'inkl. MwSt.', 'included_VAT' => 'Enthaltene MwSt', 'invoice' => 'Rechnung', 'invoice_address' => 'Rechnungsadresse', diff --git a/resources/lang/de/team.php b/resources/lang/de/team.php index efed99d3..d1393d06 100644 --- a/resources/lang/de/team.php +++ b/resources/lang/de/team.php @@ -70,6 +70,10 @@ return [ 'in_progress_yellow' => 'In Arbeit (gelb)', 'no_level_red' => 'Kein Level (rot)', + // Abo-Kennzeichen (Struktur-Ansicht) + 'badge_own_abo' => 'hat ein eigenes Abo', + 'badge_customer_abos' => '{1} 1 eigenes Kundenabo|[2,*] :count eigene Kundenabos', + // Performance & Badges 'optimized' => 'OPTIMIERT', 'standard_monitoring' => 'STANDARD + MONITORING', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 9b98a49e..770987d0 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -169,6 +169,24 @@ return [ 'error_change_locked' => 'Changes are no longer possible. The next execution is in :days days. Changes must be made at least :lock days in advance.', 'error_abo_interval_too_soon' => 'The selected delivery day is only :days days away. Please choose a delivery day at least :lock days in the future.', 'error_cancel_locked' => 'Cancellation is no longer possible. The next execution is in :days days. Cancellations must be made at least :lock days in advance.', + 'abo_copy_pause' => 'Moves the next execution back by one month. Possible at most once every :months months.', + 'payline_abo_turnover' => 'Subscription turnover in the payline', + 'payline_abo_turnover_hint' => 'Target = subscription points per cycle of all active subscriptions in your payline (consultant and customer subscriptions). Actual = subscription points of the payline already billed in the current month.', + 'payline_target_label' => 'Target (points per cycle)', + 'payline_actual_label' => 'Actual (billed this month)', + 'payline_hint' => 'Payline = :lines levels · :members consultants', + 'payline_abo_counts' => ':team_abos team subscriptions + :customer_abos customer subscriptions in the payline', + 'payline_none' => 'No payline yet (no commission level reached).', + 'abo_points_per_cycle' => 'Points per delivery (current stock)', + 'abo_points_billed' => 'billed (:month)', + 'abo_points_expected' => 'expected by day :day', + 'abo_points_expected_hint' => 'Subscription points already billed plus the points of all subscriptions on this level whose delivery is still due by day :day of the month.', + 'pause_abo' => 'Pause for 1 month', + 'confirm_pause' => 'Do you really want to pause the subscription for one month? The next delivery will move to the following month.', + 'pause_success' => 'Your subscription has been paused for one month. The next execution is on :date.', + 'error_pause_interval' => 'The subscription can be paused at most once every :months months. The next pause is possible from :date.', + 'error_pause_not_active' => 'Only active subscriptions can be paused.', + 'info_pause_available_from' => 'A pause is possible at most once every :months months — available again from :date.', 'error_pause_locked' => 'The subscription can no longer be paused. The next execution is in :days days. Pausing must be done at least :lock days in advance.', 'cancel_abo' => 'Cancel subscription', 'confirm_cancel' => 'Do you really want to cancel the subscription?', diff --git a/resources/lang/en/abo_history.php b/resources/lang/en/abo_history.php index a65b6bff..f20ac89c 100644 --- a/resources/lang/en/abo_history.php +++ b/resources/lang/en/abo_history.php @@ -36,6 +36,12 @@ return [ 'desc_rollback' => 'Reset to original', // Channels + 'status_history_title' => 'Status changes (pause & cancellation)', + 'status_paused' => 'Paused', + 'status_cancelled' => 'Cancelled', + 'status_deactivated' => 'Deactivated', + 'status_reactivated' => 'Reactivated', + 'status_shift' => 'Execution moved from :from to :to', 'channel_admin' => 'Admin', 'channel_user_me' => 'Consultant', 'channel_user_ot' => 'Consultant (Customer)', diff --git a/resources/lang/en/home.php b/resources/lang/en/home.php index 032d64dd..dafa7177 100644 --- a/resources/lang/en/home.php +++ b/resources/lang/en/home.php @@ -47,6 +47,12 @@ return [ 'until' => 'until', 'welcome_back' => 'welcome back', 'your_shop' => 'your shop', + 'commission_today' => 'Commission (as of today)', + 'payline_forecast_title' => 'Payline forecast for the 5th / 10th / 20th / 25th', + 'payline_forecast_hint' => 'Basis: current customer turnover + team turnover (payline). Plus, cumulatively, the subscription points of your payline still due up to the respective date (incl. your own subscriptions and customer subscriptions). Forecast: pauses, cancellations or failed payments can change the value.', + 'forecast_day' => 'Forecast for :date', + 'forecast_upcoming' => 'subscription points due', + 'forecast_done' => 'Date already included in actual turnover', 'monthly_statistics' => 'Monthly Statistics', 'customer_turnover_points' => 'Customer Turnover Points', 'team_turnover_points' => 'Team Turnover Points', diff --git a/resources/lang/en/msg.php b/resources/lang/en/msg.php index 3103e0b0..f8162187 100644 --- a/resources/lang/en/msg.php +++ b/resources/lang/en/msg.php @@ -3,6 +3,8 @@ return [ 'VATID_could_not_be_validated' => 'The VAT ID could not be validated, please check your entry', 'VATID_successfully_entered' => 'VAT ID entered successfully', + 'VATID_german_not_allowed' => 'German VAT IDs cannot be used here. The reverse charge procedure only applies to foreign EU VAT IDs.', + 'VATID_no_longer_valid_taxes_applied' => 'Your VAT ID is no longer valid. VAT will therefore be applied to this order.', 'abo_deaktivert' => 'subscription option disabled', 'account_released' => 'Account activated', 'booked_package_has_been_changed' => 'booked package has been changed.', @@ -40,4 +42,11 @@ return [ ], 'cart_product_not_allowed_for_order_type' => 'Your cart contains items that are not allowed for this order type. Please clear the cart and only add products that match this order.', 'cart_max_weight_reached' => 'The maximum shipping weight has been reached. No further products can be added.', + 'gdpr_delete_success' => 'GDPR deletion executed: All personal data has been irreversibly anonymized.', + 'gdpr_delete_customer_success' => 'GDPR deletion executed: All personal data of the customer has been irreversibly anonymized.', + 'gdpr_delete_customer_button' => 'GDPR-delete customer', + 'gdpr_confirm_checkbox_missing' => 'Please confirm the GDPR deletion via the checkbox.', + 'gdpr_error_is_admin' => 'GDPR deletion not possible: The account has admin rights. Please remove the admin rights first.', + 'gdpr_error_active_abo' => 'GDPR deletion not possible: There is still an active subscription. Please cancel the subscription first.', + 'gdpr_error_customer_is_member' => 'GDPR deletion not possible: This customer record belongs to a consultant account. Please use the GDPR deletion in the user administration.', ]; diff --git a/resources/lang/en/order.php b/resources/lang/en/order.php index f37333f0..1f1dcafa 100644 --- a/resources/lang/en/order.php +++ b/resources/lang/en/order.php @@ -52,6 +52,7 @@ return [ 'gross' => 'gross', 'gross_price' => 'gross price', 'incentives' => 'incentives', + 'incl_VAT' => 'incl. VAT', 'included_VAT' => 'VAT included', 'invoice' => 'invoice', 'invoice_address' => 'billing address', diff --git a/resources/lang/en/team.php b/resources/lang/en/team.php index c4318845..21bdc010 100644 --- a/resources/lang/en/team.php +++ b/resources/lang/en/team.php @@ -68,6 +68,10 @@ return [ 'in_progress_yellow' => 'In Progress (yellow)', 'no_level_red' => 'No Level (red)', + // Abo badges (structure view) + 'badge_own_abo' => 'has an own subscription', + 'badge_customer_abos' => '{1} 1 own customer subscription|[2,*] :count own customer subscriptions', + // Performance & Badges 'optimized' => 'OPTIMIZED', 'standard_monitoring' => 'STANDARD + MONITORING', diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index a6434c99..c75176b5 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -152,6 +152,24 @@ return [ 'error_change_locked' => 'Los cambios ya no son posibles. La próxima ejecución es en :days días. Los cambios deben realizarse al menos :lock días antes.', 'error_abo_interval_too_soon' => 'El día de entrega seleccionado está a solo :days días. Elija un día de entrega con al menos :lock días de anticipación.', 'error_cancel_locked' => 'La cancelación ya no es posible. La próxima ejecución es en :days días. Las cancelaciones deben realizarse al menos :lock días antes.', + 'abo_copy_pause' => 'Retrasa la próxima ejecución un mes. Posible como máximo 1 vez cada :months meses.', + 'payline_abo_turnover' => 'Volumen de suscripciones en la payline', + 'payline_abo_turnover_hint' => 'Objetivo = puntos de suscripción por ciclo de todas las suscripciones activas de tu payline (de asesores y clientes). Real = puntos de suscripción de la payline ya facturados en el mes en curso.', + 'payline_target_label' => 'Objetivo (puntos por ciclo)', + 'payline_actual_label' => 'Real (facturado este mes)', + 'payline_hint' => 'Payline = :lines niveles · :members asesores', + 'payline_abo_counts' => ':team_abos suscripciones de asesores + :customer_abos suscripciones de clientes en la payline', + 'payline_none' => 'Aún no hay payline (no se ha alcanzado ningún nivel de comisión).', + 'abo_points_per_cycle' => 'Puntos por entrega (cartera actual)', + 'abo_points_billed' => 'facturado (:month)', + 'abo_points_expected' => 'previsto hasta el día :day', + 'abo_points_expected_hint' => 'Puntos de suscripción ya facturados más los puntos de todas las suscripciones de este nivel cuya entrega aún está pendiente hasta el día :day del mes.', + 'pause_abo' => 'Pausar 1 mes', + 'confirm_pause' => '¿Realmente quieres pausar la suscripción un mes? La próxima entrega se trasladará al mes siguiente.', + 'pause_success' => 'Tu suscripción se ha pausado un mes. La próxima ejecución es el :date.', + 'error_pause_interval' => 'La suscripción puede pausarse como máximo 1 vez cada :months meses. La próxima pausa es posible a partir del :date.', + 'error_pause_not_active' => 'Solo se pueden pausar suscripciones activas.', + 'info_pause_available_from' => 'Una pausa es posible como máximo 1 vez cada :months meses — de nuevo a partir del :date.', 'error_pause_locked' => 'La suscripción ya no puede pausarse. La próxima ejecución es en :days días. La pausa debe realizarse al menos :lock días antes.', 'cancel_abo' => 'Cancelar suscripción', 'confirm_cancel' => '¿Realmente desea cancelar la suscripción?', diff --git a/resources/lang/es/abo_history.php b/resources/lang/es/abo_history.php index 4c2a66ad..a6adc360 100644 --- a/resources/lang/es/abo_history.php +++ b/resources/lang/es/abo_history.php @@ -36,6 +36,12 @@ return [ 'desc_rollback' => 'Restablecido al original', // Canales + 'status_history_title' => 'Cambios de estado (pausa y cancelación)', + 'status_paused' => 'Pausado', + 'status_cancelled' => 'Cancelado', + 'status_deactivated' => 'Desactivado', + 'status_reactivated' => 'Reactivado', + 'status_shift' => 'Ejecución trasladada del :from al :to', 'channel_admin' => 'Admin', 'channel_user_me' => 'Consultor', 'channel_user_ot' => 'Consultor (Cliente)', diff --git a/resources/lang/es/home.php b/resources/lang/es/home.php index af679065..47672ed5 100644 --- a/resources/lang/es/home.php +++ b/resources/lang/es/home.php @@ -47,6 +47,12 @@ return [ 'until' => 'hasta', 'welcome_back' => 'bienvenido de nuevo', 'your_shop' => 'tu tienda', + 'commission_today' => 'Comisión (a día de hoy)', + 'payline_forecast_title' => 'Pronóstico de payline para el 5 / 10 / 20 / 25', + 'payline_forecast_hint' => 'Base: volumen de clientes actual + volumen del equipo (payline). Más, de forma acumulada, los puntos de suscripción de tu payline pendientes hasta la fecha correspondiente (incl. tus propias suscripciones y las de tus clientes). Pronóstico: pausas, cancelaciones o pagos fallidos pueden cambiar el valor.', + 'forecast_day' => 'Pronóstico para el :date', + 'forecast_upcoming' => 'puntos de suscripción pendientes', + 'forecast_done' => 'Fecha ya incluida en el volumen real', 'monthly_statistics' => 'Estadísticas mensuales', 'customer_turnover_points' => 'Puntos de venta de clientes', 'team_turnover_points' => 'Puntos de venta del equipo', diff --git a/resources/lang/es/msg.php b/resources/lang/es/msg.php index 32357959..f528b037 100644 --- a/resources/lang/es/msg.php +++ b/resources/lang/es/msg.php @@ -3,6 +3,8 @@ return [ 'VATID_could_not_be_validated' => 'No se pudo validar el ID de IVA, verifique su entrada', 'VATID_successfully_entered' => 'ID de IVA ingresado exitosamente', + 'VATID_german_not_allowed' => 'Los ID de IVA alemanes no se pueden utilizar aquí. El procedimiento de inversión del sujeto pasivo solo se aplica a ID de IVA extranjeros de la UE.', + 'VATID_no_longer_valid_taxes_applied' => 'Tu ID de IVA ya no es válido. Por lo tanto, el pedido se calculará con IVA.', 'abo_deaktivert' => 'opción de suscripción deshabilitada', 'account_released' => 'Cuenta activada', 'booked_package_has_been_changed' => 'El paquete reservado ha sido modificado.', @@ -40,4 +42,11 @@ return [ '' => 'Su carrito de compras está vacío, agregue productos primero', ], 'cart_product_not_allowed_for_order_type' => 'El carrito contiene artículos que no están previstos para este tipo de pedido. Vacía el carrito y elige solo productos que correspondan a este pedido.', + 'gdpr_delete_success' => 'Eliminación RGPD ejecutada: Todos los datos personales han sido anonimizados de forma irreversible.', + 'gdpr_delete_customer_success' => 'Eliminación RGPD ejecutada: Todos los datos personales del cliente han sido anonimizados de forma irreversible.', + 'gdpr_delete_customer_button' => 'Eliminar cliente (RGPD)', + 'gdpr_confirm_checkbox_missing' => 'Por favor confirma la eliminación RGPD mediante la casilla de verificación.', + 'gdpr_error_is_admin' => 'Eliminación RGPD no posible: La cuenta tiene derechos de administrador. Por favor retira primero los derechos de administrador.', + 'gdpr_error_active_abo' => 'Eliminación RGPD no posible: Todavía existe una suscripción activa. Por favor cancela primero la suscripción.', + 'gdpr_error_customer_is_member' => 'Eliminación RGPD no posible: Este registro de cliente pertenece a una cuenta de asesor. Por favor utiliza la eliminación RGPD en la administración de usuarios.', ]; diff --git a/resources/lang/es/order.php b/resources/lang/es/order.php index aa083d19..6e4eb1e9 100644 --- a/resources/lang/es/order.php +++ b/resources/lang/es/order.php @@ -52,6 +52,7 @@ return [ 'gross' => 'bruto', 'gross_price' => 'precio bruto', 'incentives' => 'incentivos', + 'incl_VAT' => 'IVA incl.', 'included_VAT' => 'VAT incluido', 'invoice' => 'La factura', 'invoice_address' => 'dirección de Envio', diff --git a/resources/lang/es/team.php b/resources/lang/es/team.php index 9e2c311e..f4c0bd65 100644 --- a/resources/lang/es/team.php +++ b/resources/lang/es/team.php @@ -68,6 +68,10 @@ return [ 'in_progress_yellow' => 'En Progreso (amarillo)', 'no_level_red' => 'Sin Nivel (rojo)', + // Distintivos de suscripción (vista de estructura) + 'badge_own_abo' => 'tiene una suscripción propia', + 'badge_customer_abos' => '{1} 1 suscripción propia de cliente|[2,*] :count suscripciones propias de clientes', + // Performance & Badges 'optimized' => 'OPTIMIZADO', 'standard_monitoring' => 'ESTÁNDAR + MONITOREO', diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php index b8c46839..d0d65aaf 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -43,6 +43,24 @@ return [ 'error_change_locked' => 'Les modifications ne sont plus possibles. La prochaine exécution est dans :days jours. Les modifications doivent être effectuées au moins :lock jours avant.', 'error_abo_interval_too_soon' => 'Le jour de livraison choisi n’est qu’à :days jours. Veuillez choisir un jour de livraison situé au moins :lock jours dans le futur.', 'error_cancel_locked' => 'Une résiliation n’est plus possible. La prochaine exécution est dans :days jours. Les résiliations doivent être effectuées au moins :lock jours avant.', + 'abo_copy_pause' => 'Reporte la prochaine exécution d’un mois. Possible au maximum 1 fois tous les :months mois.', + 'payline_abo_turnover' => 'Chiffre d’affaires des abonnements dans la payline', + 'payline_abo_turnover_hint' => 'Objectif = points d’abonnement par cycle de tous les abonnements actifs de ta payline (abonnements conseillers et clients). Réel = points d’abonnement de la payline déjà facturés au cours du mois.', + 'payline_target_label' => 'Objectif (points par cycle)', + 'payline_actual_label' => 'Réel (facturé ce mois-ci)', + 'payline_hint' => 'Payline = :lines niveaux · :members conseillers', + 'payline_abo_counts' => ':team_abos abonnements conseillers + :customer_abos abonnements clients dans la payline', + 'payline_none' => 'Pas encore de payline (aucun niveau de commission atteint).', + 'abo_points_per_cycle' => 'Points par livraison (portefeuille actuel)', + 'abo_points_billed' => 'facturé (:month)', + 'abo_points_expected' => 'prévu jusqu’au :day', + 'abo_points_expected_hint' => 'Points d’abonnement déjà facturés plus les points de tous les abonnements de ce niveau dont la livraison est encore à venir d’ici le :day du mois.', + 'pause_abo' => 'Suspendre 1 mois', + 'confirm_pause' => 'Veux-tu vraiment suspendre l’abonnement pendant un mois ? La prochaine livraison sera reportée au mois suivant.', + 'pause_success' => 'Ton abonnement a été suspendu pour un mois. La prochaine exécution aura lieu le :date.', + 'error_pause_interval' => 'L’abonnement ne peut être suspendu qu’une fois tous les :months mois. La prochaine pause est possible à partir du :date.', + 'error_pause_not_active' => 'Seuls les abonnements actifs peuvent être suspendus.', + 'info_pause_available_from' => 'Une pause est possible au maximum 1 fois tous les :months mois — de nouveau à partir du :date.', 'error_pause_locked' => 'L’abonnement ne peut plus être mis en pause. La prochaine exécution est dans :days jours. La pause doit être effectuée au moins :lock jours avant.', 'error_next_date' => 'La date de la prochaine exécution est incorrecte', 'checkout_mail_abo_hl' => 'Votre abonnement / livraison régulière.', diff --git a/resources/lang/fr/abo_history.php b/resources/lang/fr/abo_history.php index f4ac1c27..54f7af50 100644 --- a/resources/lang/fr/abo_history.php +++ b/resources/lang/fr/abo_history.php @@ -27,6 +27,12 @@ return [ 'desc_comp_added' => 'Ajouté par le système', 'desc_comp_removed' => 'Supprimé par le système', 'desc_rollback' => 'Réinitialisé à l’origine', + 'status_history_title' => 'Changements de statut (pause et résiliation)', + 'status_paused' => 'Suspendu', + 'status_cancelled' => 'Résilié', + 'status_deactivated' => 'Désactivé', + 'status_reactivated' => 'Réactivé', + 'status_shift' => 'Exécution reportée du :from au :to', 'channel_admin' => 'Admin', 'channel_user_me' => 'Conseiller', 'channel_user_ot' => 'Conseiller (client)', diff --git a/resources/lang/fr/home.php b/resources/lang/fr/home.php index 583d321d..4517c1da 100644 --- a/resources/lang/fr/home.php +++ b/resources/lang/fr/home.php @@ -48,6 +48,12 @@ return [ 'until' => 'jusqu’au', 'welcome_back' => 'Bon retour', 'your_shop' => 'Votre boutique', + 'commission_today' => 'Commission (à ce jour)', + 'payline_forecast_title' => 'Prévision payline pour le 5 / 10 / 20 / 25', + 'payline_forecast_hint' => 'Base : chiffre d’affaires clients actuel + chiffre d’affaires équipe (payline). S’y ajoutent, de manière cumulée, les points d’abonnement de ta payline encore à venir jusqu’à la date concernée (y compris tes propres abonnements et abonnements clients). Prévision : pauses, résiliations ou paiements échoués peuvent modifier la valeur.', + 'forecast_day' => 'Prévision pour le :date', + 'forecast_upcoming' => 'points d’abonnement à venir', + 'forecast_done' => 'Date déjà incluse dans le chiffre réel', 'monthly_statistics' => 'Statistique mensuelle', 'customer_turnover_points' => 'Points chiffre d’affaires clients', 'team_turnover_points' => 'Points chiffre d’affaires équipe', diff --git a/resources/lang/fr/msg.php b/resources/lang/fr/msg.php index c4502e56..cb6dba4c 100644 --- a/resources/lang/fr/msg.php +++ b/resources/lang/fr/msg.php @@ -25,6 +25,8 @@ return [ 'homeparty_delete' => 'Auszeitparty supprimée', 'VATID_could_not_be_validated' => 'Le numéro de TVA n’a pas pu être validé, veuillez vérifier la saisie', 'VATID_successfully_entered' => 'Numéro de TVA saisi avec succès', + 'VATID_german_not_allowed' => 'Les numéros de TVA allemands ne peuvent pas être utilisés ici. L’autoliquidation ne s’applique qu’aux numéros de TVA étrangers de l’UE.', + 'VATID_no_longer_valid_taxes_applied' => 'Ton numéro de TVA n’est plus valide. La TVA sera donc appliquée à cette commande.', 'reverse_charge_procedure_and_VATID_deleted' => 'Procédure d’autoliquidation et numéro de TVA supprimés', 'no_id_card_deposited_please_upload_first' => 'Aucune pièce d’identité déposée, veuillez d’abord la téléverser', 'no_trade_licence_deposited_please_upload_first' => 'Aucun extrait d’activité déposé, veuillez d’abord le téléverser', @@ -38,4 +40,11 @@ return [ 'account_released' => 'Compte activé', 'cart_product_not_allowed_for_order_type' => 'Le panier contient des articles qui ne sont pas prévus pour ce type de commande. Veuillez vider le panier et sélectionner uniquement les produits adaptés à cette commande.', 'cart_max_weight_reached' => "Le poids d'expédition maximal est atteint. Aucun autre produit ne peut être ajouté.", + 'gdpr_delete_success' => 'Suppression RGPD exécutée : Toutes les données personnelles ont été anonymisées de manière irréversible.', + 'gdpr_delete_customer_success' => 'Suppression RGPD exécutée : Toutes les données personnelles du client ont été anonymisées de manière irréversible.', + 'gdpr_delete_customer_button' => 'Supprimer le client (RGPD)', + 'gdpr_confirm_checkbox_missing' => 'Veuillez confirmer la suppression RGPD via la case à cocher.', + 'gdpr_error_is_admin' => "Suppression RGPD impossible : Le compte dispose de droits d'administrateur. Veuillez d'abord retirer les droits d'administrateur.", + 'gdpr_error_active_abo' => "Suppression RGPD impossible : Un abonnement actif existe encore. Veuillez d'abord résilier l'abonnement.", + 'gdpr_error_customer_is_member' => "Suppression RGPD impossible : Cette fiche client appartient à un compte de conseiller. Veuillez utiliser la suppression RGPD dans l'administration des utilisateurs.", ]; diff --git a/resources/lang/fr/order.php b/resources/lang/fr/order.php index 253206dc..02ab1548 100644 --- a/resources/lang/fr/order.php +++ b/resources/lang/fr/order.php @@ -44,6 +44,7 @@ return [ 'gross' => 'brut', 'gross_price' => 'Prix brut', 'incentives' => 'Incentives', + 'incl_VAT' => 'TVA incl.', 'included_VAT' => 'TVA incluse', 'invoice' => 'Facture', 'invoice_address' => 'Adresse de facturation', diff --git a/resources/lang/fr/team.php b/resources/lang/fr/team.php index 60f3e59a..2f86d9a2 100644 --- a/resources/lang/fr/team.php +++ b/resources/lang/fr/team.php @@ -67,6 +67,8 @@ return [ 'qualified_green' => 'Qualifié (vert)', 'in_progress_yellow' => 'En cours (jaune)', 'no_level_red' => 'Aucun niveau (rouge)', + 'badge_own_abo' => 'a son propre abonnement', + 'badge_customer_abos' => '{1} 1 abonnement client propre|[2,*] :count abonnements clients propres', 'optimized' => 'OPTIMISÉ', 'standard_monitoring' => 'STANDARD + MONITORING', 'fallback' => 'FALLBACK', diff --git a/resources/views/admin/abo/_change_history.blade.php b/resources/views/admin/abo/_change_history.blade.php index facdfd7f..c1a1af52 100644 --- a/resources/views/admin/abo/_change_history.blade.php +++ b/resources/views/admin/abo/_change_history.blade.php @@ -1,5 +1,6 @@ @php $changeHistory = $user_abo->getChangeHistory(); + $statusHistory = $user_abo->status_histories()->with('actor.account')->orderByDesc('created_at')->orderByDesc('id')->get(); $priceLabel = $user_abo->is_for === 'me' ? __('abo_history.price_net') : __('abo_history.price_gross'); @endphp @@ -20,6 +21,34 @@ @endif + @if($statusHistory->isNotEmpty()) +
    {{ __('abo_history.status_history_title') }}
    +
    +
    + + + + + + + + + + + @foreach($statusHistory as $statusEntry) + + + + + + + + @endforeach + +
    {{ __('abo_history.col_date') }}{{ __('abo_history.col_action') }}{{ __('abo_history.col_details') }}{{ __('abo_history.col_changed_by') }}{{ __('abo_history.col_channel') }}
    {{ $statusEntry->getFormattedDate() }}{!! $statusEntry->getActionBadge() !!}{{ $statusEntry->getDescription() }}{{ $statusEntry->getActorName() }}{!! $statusEntry->getChannelBadge() !!}
    +
    + @endif + @if($changeHistory->isEmpty())

    {{ __('abo_history.no_changes') }} diff --git a/resources/views/admin/abo/_detail.blade.php b/resources/views/admin/abo/_detail.blade.php index 725f5c24..80b4552c 100644 --- a/resources/views/admin/abo/_detail.blade.php +++ b/resources/views/admin/abo/_detail.blade.php @@ -112,11 +112,11 @@

    {!! $user_abo->getIsForFormated() !!} - @if ($user_abo->is_for === 'me') + @if ($user_abo->is_for === 'me' && Route::has('user_edit')) @endif - @if ($user_abo->is_for === 'ot') + @if ($user_abo->is_for === 'ot' && $user_abo->shopping_user && Route::has('user_customer_edit')) diff --git a/resources/views/admin/abo/_order_abo_show.blade.php b/resources/views/admin/abo/_order_abo_show.blade.php index f640ae11..a73b3c7f 100644 --- a/resources/views/admin/abo/_order_abo_show.blade.php +++ b/resources/views/admin/abo/_order_abo_show.blade.php @@ -1,5 +1,7 @@ @php($cartInstance = \App\Services\AboOrderCart::INSTANCE) @php($change_locked = isset($change_locked) ? $change_locked : false) +{{-- Kunden-Portal: Versand brutto ("inkl. MwSt."), Berater/Admin: netto ("netto") --}} +@php($portal_view = $portal_view ?? false) @if(isset($error_message) && $error_message)
    {{ $error_message }}
    @endif @@ -88,9 +90,10 @@ {{ __('Delivery country') }}: {{ Yard::instance($cartInstance)->getShippingCountryName() }} + @php($shippingGross = $portal_view && ! $taxFree) - {{ __('order.shipping_costs') }}: - {{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} € + {{ __('order.shipping_costs') }} ({{ $shippingGross ? __('order.incl_VAT') : __('order.net') }}): + {{ $shippingGross ? Yard::instance($cartInstance)->shipping() : Yard::instance($cartInstance)->shippingNet() }} € @if($taxFree) diff --git a/resources/views/admin/abo/_order_combined_summary.blade.php b/resources/views/admin/abo/_order_combined_summary.blade.php index 13f28c15..83169340 100644 --- a/resources/views/admin/abo/_order_combined_summary.blade.php +++ b/resources/views/admin/abo/_order_combined_summary.blade.php @@ -1,6 +1,9 @@ @php $cartInstance = \App\Services\AboOrderCart::INSTANCE; $taxFree = Yard::instance($cartInstance)->getUserTaxFree(); + // Kunden-Portal: Versand brutto ("inkl. MwSt."), Berater/Admin: netto ("netto") + $portal_view = $portal_view ?? false; + $shippingGross = $portal_view && ! $taxFree; @endphp
      @@ -26,9 +29,10 @@ - {{ __('order.shipping_costs') }}: + {{ __('order.shipping_costs') }} + ({{ $shippingGross ? __('order.incl_VAT') : __('order.net') }}): - {{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} + {{ $shippingGross ? Yard::instance($cartInstance)->shipping() : Yard::instance($cartInstance)->shippingNet() }} € @if ($taxFree) diff --git a/resources/views/admin/abo/modal_abo_update.blade.php b/resources/views/admin/abo/modal_abo_update.blade.php index 5e4b051a..79e10781 100644 --- a/resources/views/admin/abo/modal_abo_update.blade.php +++ b/resources/views/admin/abo/modal_abo_update.blade.php @@ -90,8 +90,7 @@ $canCancel = App\Services\AboHelper::canCancelAbo($user_abo, $data['view']); @endphp
    - @if($canCancel || $data['view'] == 'admin') - + @if($data['view'] === 'admin')
    - @else - -
    -
    - {{ __('abo.info_min_duration_orders_left', ['count' => App\Services\AboHelper::getAboMinDuration() - $user_abo->getCountPaidOrders()]) }} + @elseif($canCancel) + {{-- Selfservice: kein Aktiv-Schalter mehr (die Pause übernimmt diese Rolle), + aktueller Zustand wird unverändert mitgesendet. --}} + + + @php + $canPause = App\Services\AboHelper::canPauseAbo($user_abo, $data['view']); + $nextPossiblePause = App\Services\AboHelper::nextPossiblePauseDate($user_abo); + @endphp + @if($canPause || ($user_abo->active && $nextPossiblePause)) +
    +
    + @if($canPause) + +
    + {{ __('abo.abo_copy_pause', ['months' => App\Services\AboHelper::getPauseIntervalMonths()]) }} +
    + @else + {{ __('abo.info_pause_available_from', ['date' => $nextPossiblePause->format(Util::formatDateDB()), 'months' => App\Services\AboHelper::getPauseIntervalMonths()]) }} + @endif
    -
    +
    +
    @endif + +
    +
    + +
    +
    + @else + +
    +
    + {{ __('abo.info_min_duration_orders_left', ['count' => App\Services\AboHelper::getAboMinDuration() - $user_abo->getCountPaidOrders()]) }} +
    +
    + @endif @endif
    diff --git a/resources/views/admin/customer/detail.blade.php b/resources/views/admin/customer/detail.blade.php index e739a0ab..ab545c81 100644 --- a/resources/views/admin/customer/detail.blade.php +++ b/resources/views/admin/customer/detail.blade.php @@ -9,4 +9,40 @@ @include('admin.customer._detail') {{ __('back') }} + @if (Auth::user()->isSuperAdmin() && !$shopping_user->auth_user_id) + + + + + @endif + @endsection \ No newline at end of file diff --git a/resources/views/admin/payment/modal_credit_status.blade.php b/resources/views/admin/payment/modal_credit_status.blade.php index fb67bf7d..ea33f559 100644 --- a/resources/views/admin/payment/modal_credit_status.blade.php +++ b/resources/views/admin/payment/modal_credit_status.blade.php @@ -50,15 +50,15 @@ --}} Bankinhaber -
    {{ $UserCredit->user->account->bank_owner }}
    +
    {{ $UserCredit->user?->account?->bank_owner }}
    IBAN: -
    {{ $UserCredit->user->account->bank_iban }}
    +
    {{ $UserCredit->user?->account?->bank_iban }}
    BIC: -
    {{ $UserCredit->user->account->bank_bic }}
    +
    {{ $UserCredit->user?->account?->bank_bic }}
    diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php index 3ebb3725..5ecbb1cd 100755 --- a/resources/views/admin/settings/index.blade.php +++ b/resources/views/admin/settings/index.blade.php @@ -124,6 +124,14 @@ Anzahl Tage vor der Ausführung, ab der das Abo nicht mehr pausiert oder gekündigt werden kann (Standard: 3).
    +
    +
    + + {{ Form::text('settings[abo-pause-interval-months][val]', \App\Models\Setting::getContentBySlug('abo-pause-interval-months'), array('class'=>'form-control')) }} + {{ Form::hidden('settings[abo-pause-interval-months][type]', 'int') }} + Maximal eine Pause pro X Monate (rollierend ab der letzten Pause, Standard: 6). +
    +
    diff --git a/resources/views/admin/user/index.blade.php b/resources/views/admin/user/index.blade.php index 6020610b..207723b8 100644 --- a/resources/views/admin/user/index.blade.php +++ b/resources/views/admin/user/index.blade.php @@ -359,6 +359,19 @@ User komplett ohne Wiederherstellung löschen?
    +
    +
    +
    DSGVO-Löschung (Art. 17 DSGVO)
    +

    Alle personenbezogenen Daten dieses Users werden unwiderruflich anonymisiert + (Stammdaten, Adressen, Bankdaten, Uploads, Zahlungs-Tokens, Kontaktdaten). + Die Downline wird auf den nächsten aktiven Sponsor übertragen. + Rechnungen, Gutschriften und Provisionsdaten bleiben wegen der gesetzlichen + Aufbewahrungspflichten erhalten. Aktive Abos müssen vorher gekündigt werden.

    + +
    @@ -143,6 +160,37 @@ @endforeach @endforeach + + @if (! empty($s['payline_forecast']['days'])) +
    + {{ __('home.payline_forecast_title') }} + Live +
    +
    + @foreach ($s['payline_forecast']['days'] as $forecastDay) +
    +
    +
    +
    + {{ __('home.forecast_day', ['date' => $forecastDay['date']]) }} +
    +
    {{ $fmt($forecastDay['total']) }}
    + @if ($forecastDay['upcoming'] > 0) +
    + +{{ $fmt($forecastDay['upcoming']) }} {{ __('home.forecast_upcoming') }} +
    + @else +
    + {{ $forecastDay['is_past'] ? __('home.forecast_done') : '—' }} +
    + @endif +
    +
    +
    + @endforeach +
    + @endif @@ -175,6 +223,14 @@ $('#stats-month-filter, #stats-year-filter').on('change', function() { updateStatsFilter(); }); + + // Das "+N"-Badge liegt innerhalb des Kachel-Links: eigenen Ziel-Link + // öffnen statt der Kachel-Navigation (kein verschachteltes möglich). + $('.dashboard-stat-new-badge').css('cursor', 'pointer').on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + window.location.href = $(this).data('href'); + }); }); @endif diff --git a/resources/views/portal/abo/_create_check.blade.php b/resources/views/portal/abo/_create_check.blade.php index b6497e7c..04dfc480 100644 --- a/resources/views/portal/abo/_create_check.blade.php +++ b/resources/views/portal/abo/_create_check.blade.php @@ -152,10 +152,13 @@ {{ Yard::instance('subscription')->getShippingCountryName() }} + @php($checkTaxFree = Yard::instance('subscription')->getUserTaxFree()) - {{ __('order.shipping_costs') }}: + {{ __('order.shipping_costs') }} + ({{ $checkTaxFree ? __('order.net') : __('order.incl_VAT') }}): - {{ Yard::instance('subscription')->shipping() }} € + {{ $checkTaxFree ? Yard::instance('subscription')->shippingNet() : Yard::instance('subscription')->shipping() }} + € @if (Yard::instance('subscription')->getUserTaxFree()) diff --git a/resources/views/portal/abo/my_abo.blade.php b/resources/views/portal/abo/my_abo.blade.php index dd652d53..d4129ebd 100755 --- a/resources/views/portal/abo/my_abo.blade.php +++ b/resources/views/portal/abo/my_abo.blade.php @@ -93,6 +93,7 @@ 'split_mode' => true, 'summary' => $summary, 'change_locked' => $aboChangeLocked, + 'portal_view' => true, ])
    @@ -106,7 +107,7 @@
    - @include('admin.abo._order_combined_summary', ['summary' => $summary]) + @include('admin.abo._order_combined_summary', ['summary' => $summary, 'portal_view' => true])
    @@ -115,6 +116,7 @@ @include('admin.abo._order_abo', [ 'add_only_mode' => $addOnlyMode, 'change_locked' => $aboChangeLocked, + 'portal_view' => true, ]) diff --git a/resources/views/sys/index.blade.php b/resources/views/sys/index.blade.php index a29a5417..5a7bb207 100644 --- a/resources/views/sys/index.blade.php +++ b/resources/views/sys/index.blade.php @@ -23,8 +23,6 @@ class="ion ion-ios-arrow-forward">  Kundenhoheit prüfen
      Cron Jobs -   Subdomains prüfen SSL/Aktiv   Shopping Orders Käufe any()) -
    -
    -
    -
      - @foreach ($errors->all() as $error) -
    • {{ $error }}
    • - @endforeach -
    -
    -
    -
    - @endif - - -

    - Subdomains prüfen SSL/Aktiv -

    - -
    - - - - - - @if(count($dobbleDomains)>0) - {!! Form::open(['action' => route('sysadmin_tool_store', ['domainssl']), 'class' => '']) !!} -
    -
    - - - - - - - - - - - @foreach($dobbleDomains as $userId=>$domains) - @foreach($domains as $domainId=>$slug) - - - - - - - - @endforeach - @endforeach - - -
    IDUser-IDSubDomain#
    {{ $domainId }}{{ $userId }}{{ $slug }} - -
    - -
    - -
    - {!! Form::close() !!} - @endif - - @if(count($SubDomains)>0) - {!! Form::open(['action' => route('sysadmin_tool_store', ['domainssl']), 'class' => '']) !!} -
    -
    - - - - - - - - - - @foreach($SubDomains as $SubDomain=>$ssl) - - - - - - @endforeach - - -
    SubDomainSSL#
    {{ $SubDomain }}@if(!is_array($ssl)){{ $ssl }}@endif - -
    - -
    - -
    - {!! Form::close() !!} - @endif - - @if(count($values)>0) - {!! Form::open(['action' => route('sysadmin_tool_store', ['domainssl']), 'class' => '']) !!} - -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - @foreach($values as $value) - - - - - - - - - - - - - - - - - - - - - @endforeach - -
    IDUser-IDDomainSubSSL#PHPseitDatumErreichbarStatusUser AccountUser ShopUser seit#
    {{ $value->id }}{{ $value->user_id }}{{ $value->slug }}.mivita.care {!! get_active_badge($value->hasSubdomain) !!} {!! get_active_badge($value->hasSSL) !!} {!! $value->PHPversion !!} {{ $value->getActiveDateFormatSmall() }} - @if($value->user && $value->user->payment_shop) - @if($value->user->isActiveShop()) - {{ $value->user->getPaymentShopDateFormat() }} - @else - {{ $value->user->getPaymentShopDateFormat() }} - @endif - @else - - @endif - - - -- - {{-- - @if($value->getSubdomainAvailable()) - {{ __('available') }} - @if($value->getSubdomainSslSin()) HTTPS @else HTTP @endif - - @else - {{ __('not available') }} HTTP - @endif - --}} - {!! get_active_badge($value->active) !!}@if($value->user) - @if($value->user->payment_account) - @if($value->user->isActiveAccount()) - {{ $value->user->getPaymentAccountDateFormat() }} - @else - {{ $value->user->getPaymentAccountDateFormat() }} - @endif - @else - - @endif - @else - - - @endif - - @if($value->user) - @if($value->user->payment_shop) - @if($value->user->isActiveShop()) - {{ $value->user->getPaymentShopDateFormat() }} - @else - {{ $value->user->getPaymentShopDateFormat() }} - @endif - @else - - @endif - @else - - - @endif - - @if($value->user) - @if($value->user->shop) - @if($value->user->shop->active) - {{ $value->user->shop->getActiveDateFormatSmall() }} - @else - {{ $value->user->shop->getActiveDateFormatSmall() }} - @endif - @else - - @endif - @else - - - @endif - - @if(!$value->user || !$value->user->payment_shop || !$value->user->isActiveShop()) - - @else - - @endif -
    -
    -
    - {!! Form::close() !!} - @endif -
    -@endsection - diff --git a/resources/views/user/backoffice/statistics/details.blade.php b/resources/views/user/backoffice/statistics/details.blade.php index 467955e8..f1d51d9b 100644 --- a/resources/views/user/backoffice/statistics/details.blade.php +++ b/resources/views/user/backoffice/statistics/details.blade.php @@ -5,6 +5,9 @@
    {{ __('navigation.statistics') }} / {{ $details['metric_label'] }} VIP + @if (! empty($details['only_new'])) + Nur neue im {{ $details['month'] }}/{{ $details['year'] }} + @endif
    CSV @@ -95,6 +99,11 @@ Sonstige Punkte Kundenpunkte Gesamtpunkte + @elseif ($details['metric'] === 'new_partners') + Sponsor + Kundenpunkte + Aktiv seit + Account gültig bis @else Aktiv seit Account gültig bis @@ -157,6 +166,11 @@ {{ \App\Services\Util::formatNumber($row['customer_other_points']) }} {{ \App\Services\Util::formatNumber($row['external_points']) }} {{ \App\Services\Util::formatNumber($row['total_points']) }} + @elseif ($details['metric'] === 'new_partners') + {{ $row['sponsor'] ?? '-' }} + {{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }} + {{ $row['active_date'] ?? '-' }} + {{ $row['payment_account'] ?? '-' }} @else {{ $row['active_date'] ?? '-' }} @@ -194,6 +208,11 @@ {{ \App\Services\Util::formatNumber($details['summary']['customer_other_points']) }} {{ \App\Services\Util::formatNumber($details['summary']['external_points']) }} {{ \App\Services\Util::formatNumber($details['summary']['total_points']) }} + @elseif ($details['metric'] === 'new_partners') + + {{ \App\Services\Util::formatNumber($details['summary']['customer_points'] ?? 0) }} + + @else diff --git a/resources/views/user/backoffice/statistics/index.blade.php b/resources/views/user/backoffice/statistics/index.blade.php index 505f7c13..4762975f 100644 --- a/resources/views/user/backoffice/statistics/index.blade.php +++ b/resources/views/user/backoffice/statistics/index.blade.php @@ -26,6 +26,9 @@ @if (!empty($performance['calculated_at'])) Snapshot vom {{ $performance['calculated_at'] }} @endif + @if (!empty($statistics['abo_stock_live'])) + Abo-Bestände tagesaktuell + @endif
    @endif @@ -86,11 +89,32 @@ }; }; + // Ziel-Link des grünen "+N"-Badges: Neupartner-Liste bzw. Abo-Drilldown + // gefiltert auf die im Monat neuen Abos (only_new). + $newBadgeLink = function (int $lineNo, string $metric) use ($selectedMonth, $selectedYear): string { + if ($metric === 'consultants') { + return route('user_backoffice_statistics_details', [ + 'line' => $lineNo, + 'metric' => 'new_partners', + 'month' => $selectedMonth, + 'year' => $selectedYear, + ]); + } + + return route('user_backoffice_statistics_details', [ + 'line' => $lineNo, + 'metric' => $metric, + 'month' => $selectedMonth, + 'year' => $selectedYear, + 'only_new' => 1, + ]); + }; + $selectedMonthName = $filterMonths[(int) $selectedMonth] ?? $selectedMonth; @endphp
    -
    +
    @@ -167,8 +217,9 @@ @php $newMetric = $newAboMetric($line, $metric); @endphp @if ($newMetric && $line[$newMetric] > 0) - +{{ number_format($line[$newMetric], 0, ',', '.') }} + +{{ number_format($line[$newMetric], 0, ',', '.') }} @endif @else @php $newMetric = $newAboMetric($statistics['totals'], $metric); @endphp @if ($newMetric && $statistics['totals'][$newMetric] > 0) - +{{ number_format($statistics['totals'][$newMetric], 0, ',', '.') }} + +{{ number_format($statistics['totals'][$newMetric], 0, ',', '.') }} @endif @else
    + @php $nonPaylinePoints = $statistics['non_payline_points'] ?? null; @endphp + @if ($nonPaylinePoints !== null && $nonPaylinePoints > 0) +
    +

    + In den Gesamtpunkten enthalten: + {{ \App\Services\Util::formatNumber($nonPaylinePoints) }} Punkte, + die nicht in die Payline fließen (z. B. Neupartner-Einschreibungspunkte) — + die Punkte-Summe der Struktur-/Provisionsansicht fällt deshalb um diesen Betrag niedriger aus. +

    +
    + @endif
    @@ -253,8 +316,13 @@ @foreach ($newCustomerAbos as $row) -
    {{ $row['name'] }}
    -
    Linie {{ $row['line'] }} · {{ $row['career_level'] }}
    +
    + {{ $row['name'] }} + @if (!empty($row['is_self'])) + ich + @endif +
    +
    {{ !empty($row['is_self']) ? 'Eigene Kundenabos' : 'Linie '.$row['line'] }} · {{ $row['career_level'] }}
    @@ -288,7 +356,9 @@ @if ($leaderAbos !== null)
    {{ number_format((float) ($leaderAbos['total_own'] ?? 0), 0, ',', '.') }} eigene · - {{ number_format((float) ($leaderAbos['total_customer'] ?? 0), 0, ',', '.') }} Kunden + {{ number_format((float) ($leaderAbos['total_customer'] ?? 0), 0, ',', '.') }} Kunden · + {{ number_format((float) ($leaderAbos['total_team'] ?? 0), 0, ',', '.') }} Team · + {{ number_format((float) ($leaderAbos['total_team_customer'] ?? 0), 0, ',', '.') }} Teamkunden
    @endif @@ -313,32 +383,171 @@ Führungskraft Eigene Abos Kundenabos + Teamabos (direkt) + Team-Kundenabos (direkt) + Teamabos (gesamt) + Team-Kundenabos (gesamt) + Gesamt @foreach ($leaderAbos['rows'] as $row) + @php + $leaderTotal = $row['total_abo_count_full'] + ?? (($row['own_abo_count'] ?? 0) + ($row['customer_abo_count'] ?? 0) + ($row['team_abo_count_total'] ?? ($row['team_abo_count'] ?? 0)) + ($row['team_customer_abo_count_total'] ?? ($row['team_customer_abo_count'] ?? 0))); + @endphp
    {{ $row['name'] }}
    Linie {{ $row['line'] }} · {{ $row['career_level'] }}
    - - @if (($row['own_abo_count'] ?? 0) > 0) - - {{ number_format((float) $row['own_abo_count'], 0, ',', '.') }} - - @else - 0 - @endif + @foreach (['own_abo_count', 'customer_abo_count', 'team_abo_count', 'team_customer_abo_count', 'team_abo_count_total', 'team_customer_abo_count_total'] as $countKey) + + @if (($row[$countKey] ?? 0) > 0) + + {{ number_format((float) $row[$countKey], 0, ',', '.') }} + + @else + 0 + @endif + + @endforeach + + {{ number_format((float) $leaderTotal, 0, ',', '.') }} + + + @endforeach + + + +
    +

    + direkt = Team bis zur nächsten Führungskraft (überschneidungsfrei, ergibt die Kopf-Summe). + gesamt = komplette Downline inklusive der Teams untergeordneter Führungskräfte — dieselben Abos + können dort bei mehreren Führungskräften zählen, die Gesamt-Spalte lässt sich deshalb nicht aufsummieren. +

    +
    + @endif + + + + + @php + $topCustomerAbos = $statistics['top_customer_abos'] ?? null; + @endphp + +
    +
    +
    +
    +
    Top 10 Kundenabos (Anzahl)
    +
    + aktueller Bestand aktiver Kundenabos · weicht bewusst von der Incentive-Zählung ab + +
    +
    + @if ($topCustomerAbos === null) +
    +

    + Für diesen Monat liegt noch ein älterer Snapshot ohne diese Liste vor. +

    +
    + @elseif (count($topCustomerAbos['by_count']) === 0) +
    +

    Im Team gibt es aktuell keine aktiven Kundenabos.

    +
    + @else +
    + + + + + + + + + + @foreach ($topCustomerAbos['by_count'] as $row) + + + + + @endforeach + +
    PlatzBeraterKundenabos
    {{ $loop->iteration }}. +
    + {{ $row['name'] }} + @if (!empty($row['is_self'])) + ich + @endif +
    +
    {{ !empty($row['is_self']) ? 'Eigene Kundenabos' : 'Linie '.$row['line'] }} · {{ $row['career_level'] }}
    - @if (($row['customer_abo_count'] ?? 0) > 0) - - {{ number_format((float) $row['customer_abo_count'], 0, ',', '.') }} - - @else - 0 - @endif + + {{ number_format((float) $row['abo_count'], 0, ',', '.') }} + +
    +
    + @endif +
    +
    +
    +
    +
    +
    Top 10 Kundenabos (Umsatz)
    +
    + sortiert nach abgerechneten Kundenabo-Punkten {{ $selectedMonthName }} {{ $selectedYear }} + +
    +
    + @if ($topCustomerAbos === null) +
    +

    + Für diesen Monat liegt noch ein älterer Snapshot ohne diese Liste vor. +

    +
    + @elseif (count($topCustomerAbos['by_points']) === 0) +
    +

    Im Team gibt es aktuell keine Kundenabo-Umsätze.

    +
    + @else +
    + + + + + + + + + + + + @foreach ($topCustomerAbos['by_points'] as $row) + + + + + + @endforeach diff --git a/resources/views/user/components/user_shop_edit.blade.php b/resources/views/user/components/user_shop_edit.blade.php index 1ffdfe54..404f3b2a 100644 --- a/resources/views/user/components/user_shop_edit.blade.php +++ b/resources/views/user/components/user_shop_edit.blade.php @@ -80,32 +80,6 @@ - {{--
  12. -
    {{ __('Status') }}
    -
    - @if($user->shop->getSubdomainStatus()) - {{ __('shop.available') }} DNS
    - @else - {{ __('shop.not_available') }} DNS - @endif - - @if($user->shop->getSubdomainAvailable()) - {{ __('shop.available') }} - @if($user->shop->getSubdomainSslSin()) HTTPS @else HTTP @endif - - @else - {{ __('shop.not_available') }} HTTP - @endif - - @if($user->shop->getSubdomainSslSinActive()) - {{ __('shop.available') }} SSL - - @else - {{ __('shop.not_available') }} SSL - @endif - -
    -
  13. --}}
  14. {{ __('shop.active_since') }}
    diff --git a/resources/views/user/order/yard_view_form.blade.php b/resources/views/user/order/yard_view_form.blade.php index 3d297d45..175c650b 100644 --- a/resources/views/user/order/yard_view_form.blade.php +++ b/resources/views/user/order/yard_view_form.blade.php @@ -287,7 +287,7 @@
  15. - + diff --git a/resources/views/user/team/abos.blade.php b/resources/views/user/team/abos.blade.php index 153acf87..a8618bc8 100644 --- a/resources/views/user/team/abos.blade.php +++ b/resources/views/user/team/abos.blade.php @@ -38,6 +38,46 @@ + {{-- Abo-Umsatz in der Payline (Soll/Ist, Live) --}} + @if (isset($paylineAboTurnover)) +
    +
    +
    +
    +
    + {{ __('abo.payline_abo_turnover') }} + Live +
    + @if (($paylineAboTurnover['paylines'] ?? 0) > 0) +
    +
    +
    {{ __('abo.payline_target_label') }}
    +
    + {{ \App\Services\Util::formatNumber($paylineAboTurnover['target']) }} +
    +
    +
    +
    {{ __('abo.payline_actual_label') }}
    +
    + {{ \App\Services\Util::formatNumber($paylineAboTurnover['actual']) }} +
    +
    +
    +
    + {{ __('abo.payline_hint', ['lines' => $paylineAboTurnover['paylines'], 'members' => $paylineAboTurnover['members']]) }} +
    + {{ __('abo.payline_abo_counts', ['team_abos' => $paylineAboTurnover['team_abo_count'] ?? 0, 'customer_abos' => $paylineAboTurnover['team_customer_abo_count'] ?? 0]) }} +
    + @else +

    {{ __('abo.payline_none') }}

    + @endif +
    +
    +
    +
    + @endif + {{-- Linien-/Ebenen-Tabelle --}}
    @@ -53,7 +93,9 @@
    - + + + @@ -77,9 +119,11 @@ + + -
    PlatzBeraterAbosAbgerechnet (Punkte)Bestand/Lieferung
    {{ $loop->iteration }}. +
    + {{ $row['name'] }} + @if (!empty($row['is_self'])) + ich + @endif +
    +
    {{ !empty($row['is_self']) ? 'Eigene Kundenabos' : 'Linie '.$row['line'] }} · {{ $row['career_level'] }}
    +
    + + {{ number_format((float) ($row['abo_count'] ?? 0), 0, ',', '.') }} + + + {{ \App\Services\Util::formatNumber($row['points_actual']) }} + + {{ \App\Services\Util::formatNumber($row['points_target']) }}
    {{ Yard::instance('shopping')->getShippingCountryName() }}
    {{ __('order.shipping_costs') }}:{{ __('order.shipping_costs') }} ({{ __('order.net') }}): @php($shippingFree = Yard::instance('shopping')->getShippingFree()) diff --git a/resources/views/user/shop/sales/api_order_list_total.blade.php b/resources/views/user/shop/sales/api_order_list_total.blade.php index abf57e4f..23b49b7e 100644 --- a/resources/views/user/shop/sales/api_order_list_total.blade.php +++ b/resources/views/user/shop/sales/api_order_list_total.blade.php @@ -44,7 +44,7 @@ @endforeach
    - {{ __('order.total_shipping_costs') }} + {{ __('order.total_shipping_costs') }} ({{ __('order.net') }}) {{ __('abo.team_customer_abos') }} {{ __('abo.new_this_month') }} ({{ $currentMonthName }}) {{ __('abo.team_total') }}{{ __('abo.abo_count_points') }}{{ __('abo.abo_points_per_cycle') }}{{ __('abo.abo_points_billed', ['month' => $currentMonthName]) }}{{ __('abo.abo_points_expected', ['day' => 25]) }}
    +{{ $line['own_abos_new'] + $line['customer_abos_new'] }} {{ $line['total_abos'] }} {{ \App\Services\Util::formatNumber($line['own_abo_points'] + $line['customer_abo_points']) }}{{ \App\Services\Util::formatNumber($line['abo_points_billed'] ?? 0) }}{{ \App\Services\Util::formatNumber($line['abo_points_expected'] ?? 0) }}
    +
    @@ -129,7 +173,7 @@ @empty - + @endforelse @@ -143,6 +187,8 @@ + + @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())
    - {{ Yard::instance($yard_instance)->shipping() }} € - {{ __('order.shipping_costs') }}: + {{ $cardShippingTaxFree ? Yard::instance($yard_instance)->shippingNet() : Yard::instance($yard_instance)->shipping() }} € + {{ __('order.shipping_costs') }} ({{ $cardShippingTaxFree ? __('order.net') : __('order.incl_VAT') }}):

    @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 @@ + @php($checkoutShippingTaxFree = Yard::instance($yard_instance)->getUserTaxFree()) - - + + @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')); + } +}
    {{ __('tables.no_data_available') }}{{ __('tables.no_data_available') }}
    +{{ $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) }}
    {{ __('Delivery country') }}: {{ Yard::instance($yard_instance)->getShippingCountryName() }}
    {{ __('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() }} €