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

Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert

Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-24 13:51:16 +00:00
parent c06740b9be
commit d4e9d108ad
137 changed files with 7610 additions and 2955 deletions

View file

@ -1,214 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\Api\KasController;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class DeleteAllKasSubdomains extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'subdomains:delete-all-kas
{--domain= : Optional nur Subdomains dieser Hauptdomain löschen}
{--limit= : Maximale Anzahl zu löschender Subdomains}
{--list-file= : Optionaler Pfad für die temporäre JSON-Liste}
{--force : Subdomains wirklich löschen; ohne diese Option läuft nur ein Dry-Run}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Löscht alle aktuell in KAS angelegten Subdomains anhand einer temporären KAS-Liste';
public function __construct(private KasController $kas)
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$domain = $this->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;
}
}

View file

@ -1,241 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\Api\KasController;
use App\Models\UserShop;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class DeletePartnerShopSubdomains extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'subdomains:delete-partnershops
{--domain=mivita.care : Hauptdomain der Partnershop-Subdomains}
{--start=1 : UserShop-ID, ab der Shops berücksichtigt werden}
{--limit= : Maximale Anzahl zu löschender Subdomains}
{--active-only : Nur aktive/nicht gelöschte UserShops berücksichtigen}
{--include-path-matches : Zusätzlich KAS-Subdomains mit dem Partnershop-Pfad löschen}
{--path=/mein.mivita.care/public/ : Zielpfad der alten Partnershop-Subdomains}
{--force : Subdomains wirklich löschen; ohne diese Option läuft nur ein Dry-Run}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Löscht alte KAS-Subdomains der Partnershops nach Umstellung auf Wildcard-Konfiguration';
/**
* @var array<int, string>
*/
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;
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Exceptions\GdprDeletionException;
use App\Models\ShoppingUser;
use App\Services\GdprDeletionService;
use App\User;
use Illuminate\Console\Command;
class GdprDeleteUser extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gdpr:delete
{id : User-ID (Berater) bzw. mit --customer die ShoppingUser-ID (Endkunde)}
{--customer : Endkunde (shopping_users) statt Berater (users) löschen}
{--force : Sicherheitsabfrage überspringen}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'DSGVO-Löschung (Art. 17): Anonymisiert alle personenbezogenen Daten eines Beraters oder Endkunden unwiderruflich. Aufbewahrungspflichtige Daten (Rechnungen, Gutschriften, Provisionen) bleiben erhalten.';
/**
* Execute the console command.
*/
public function handle(GdprDeletionService $gdprDeletionService): int
{
$id = (int) $this->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;
}
}
}

View file

@ -1,664 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\Api\KasController;
use App\Http\Controllers\Api\KasSLLController;
use App\Models\UserShop;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SubDomains extends Command
{
/**
* Die Signatur des Konsolenbefehls.
*
* Aufruf: php artisan subdomains:action --force --start=4 --debug
* /usr/bin/php82 artisan subdomains:action --force --start=4 --create-missing --debug
* /usr/bin/php82 artisan subdomains:action --force --debug
* @var string
*/
protected $signature = 'subdomains:action {user_id?} {--force} {--start=1} {--create-missing} {--debug}';
/**
* Die Beschreibung des Konsolenbefehls.
*
* @var string
*/
protected $description = 'Passt Parameter für die User-Subdomains an (PHP-Version, SSL)';
/**
* Zeitstempel für die Ausführungszeit-Messung
*
* @var float
*/
private $timeStart;
/**
* Standard-Domain für alle Subdomains
*
* @var string
*/
private $domain = 'mivita.care';
/**
* Zu überspringende Subdomain-Präfixe
*
* @var array
*/
private $skipPrefixes = ['www.', 'api.', 'checkout.', 'preview.'];
/**
* Erstellt eine neue Befehlsinstanz.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Führt den Konsolenbefehl aus.
*
* @return int
*/
public function handle()
{
$this->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");
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* Wird geworfen, wenn eine DSGVO-Löschung nicht ausgeführt werden kann
* (z. B. Admin-Account, aktives Abo, Datensatz gehört zu einem Berater).
*/
class GdprDeletionException extends Exception {}

View file

@ -117,6 +117,7 @@ class AboController extends Controller
if (isset($data['action'])) {
if ($data['action'] === 'abo_update_settings') {
$user_abo = UserAbo::findOrFail($data['id']);
$data['view'] = 'admin';
$this->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'])

View file

@ -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 '<span class="badge badge-pill badge-danger">' . $user->email . '</span>';
return '<span class="badge badge-pill badge-danger">'.$user->email.'</span>';
}
return $user->email;
})
->addColumn('last_name', function (User $user) {
return $user->account ? $user->account->last_name : '';
})
->addColumn('id', function (User $user) {
return '<a href="' . route('admin_lead_edit', [$user->id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
return '<a href="'.route('admin_lead_edit', [$user->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
})
->addColumn('admin', function (User $user) {
return '<a href="#" data-toggle="modal" data-target="#modals-admin" data-id="' . $user->id . '" data-email="' . $user->email . '" data-admin="' . $user->admin . '">' . HTMLHelper::getRoleLabel($user->admin) . '</a>';
return '<a href="#" data-toggle="modal" data-target="#modals-admin" data-id="'.$user->id.'" data-email="'.$user->email.'" data-admin="'.$user->admin.'">'.HTMLHelper::getRoleLabel($user->admin).'</a>';
})
->addColumn('confirmed', function (User $user) {
$date = $user->getConfirmationDateFormat();
$link = '<a href="#" data-toggle="modal" data-target="#modals-confirmed" data-id="' . $user->id . '" data-email="' . $user->email . '" data-confirmed="' . $user->confirmed . '" data-confirmation_date="' . $date . '">';
return $user->confirmed ? $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $date . '</span></a>' : $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
$link = '<a href="#" data-toggle="modal" data-target="#modals-confirmed" data-id="'.$user->id.'" data-email="'.$user->email.'" data-confirmed="'.$user->confirmed.'" data-confirmation_date="'.$date.'">';
return $user->confirmed ? $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$date.'</span></a>' : $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
})
->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 = '<a href="#" data-toggle="modal" data-target="#modals-restore" data-id="' . $user->id . '" data-email="' . $email . '" data-active="' . $user->active . '" data-payment_account="' . $date . '">';
return $link . '<span class="badge badge-pill badge-info"><i class="fa fa-undo"></i> Account reaktivieren</span></a>';
$email = str_replace('delete-', '', $user->email);
$link = '<a href="#" data-toggle="modal" data-target="#modals-restore" data-id="'.$user->id.'" data-email="'.$email.'" data-active="'.$user->active.'" data-payment_account="'.$date.'">';
return $link.'<span class="badge badge-pill badge-info"><i class="fa fa-undo"></i> Account reaktivieren</span></a>';
}
$date = $user->getActiveDateFormat();
$link = '<a href="#" data-toggle="modal" data-target="#modals-active" data-id="' . $user->id . '" data-email="' . $user->email . '" data-active="' . $user->active . '" data-active_date="' . $date . '">';
return $user->active ? $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $date . '</span></a>' : $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
$link = '<a href="#" data-toggle="modal" data-target="#modals-active" data-id="'.$user->id.'" data-email="'.$user->email.'" data-active="'.$user->active.'" data-active_date="'.$date.'">';
return $user->active ? $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$date.'</span></a>' : $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
})
->addColumn('account', function (User $user) {
$date = $user->getPaymentAccountDateFormat();
$link = '<a href="#" data-toggle="modal" data-target="#modals-account" data-id="' . $user->id . '" data-email="' . $user->email . '" data-payment_account="' . $date . '">';
$link = '<a href="#" data-toggle="modal" data-target="#modals-account" data-id="'.$user->id.'" data-email="'.$user->email.'" data-payment_account="'.$date.'">';
if ($user->payment_account) {
if ($user->isActiveAccount()) {
return $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $date . '</span></a>';
return $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$date.'</span></a>';
}
return $link . '<span class="badge badge-pill badge-warning"><i class="fa fa-ban"></i> ' . $date . '</span></a>';
return $link.'<span class="badge badge-pill badge-warning"><i class="fa fa-ban"></i> '.$date.'</span></a>';
}
return $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
return $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
})
->addColumn('shop', function (User $user) {
$date = $user->getPaymentShopDateFormat();
$link = '<a href="#" data-toggle="modal" data-target="#modals-shop" data-id="' . $user->id . '" data-email="' . $user->email . '" data-payment_shop="' . $date . '">';
$link = '<a href="#" data-toggle="modal" data-target="#modals-shop" data-id="'.$user->id.'" data-email="'.$user->email.'" data-payment_shop="'.$date.'">';
if ($user->payment_shop) {
if ($user->isActiveShop()) {
return $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $date . '</span></a>';
return $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$date.'</span></a>';
}
return $link . '<span class="badge badge-pill badge-warning"><i class="fa fa-ban"></i> ' . $date . '</span></a>';
return $link.'<span class="badge badge-pill badge-warning"><i class="fa fa-ban"></i> '.$date.'</span></a>';
}
return $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
return $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
})
->addColumn('shop_domain', function (User $user) {
return $user->shop ? '<a href="' . $user->shop->getSubdomain(false) . '" target="_blank">' . $user->shop->getSubdomain(false) . '</a>' : '';
return $user->shop ? '<a href="'.$user->shop->getSubdomain(false).'" target="_blank">'.$user->shop->getSubdomain(false).'</a>' : '';
})
->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 = '<a href="#" data-toggle="modal" data-target="#modals-payment_methods" data-id="' . $user->id . '" data-email="' . $user->email . '" data-payment_methods="' . htmlspecialchars($payment_methods) . '">';
if (!$user->payment_methods) {
return $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
$link = '<a href="#" data-toggle="modal" data-target="#modals-payment_methods" data-id="'.$user->id.'" data-email="'.$user->email.'" data-payment_methods="'.htmlspecialchars($payment_methods).'">';
if (! $user->payment_methods) {
return $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
}
return $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> ' . $user->getPaymentMethodsShort() . '</span></a>';
return $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i> '.$user->getPaymentMethodsShort().'</span></a>';
})
->addColumn('action_login', function (User $user) {
return '<a href="' . route('admin_user_login_as', [$user->id]) . '" class="btn icon-btn btn-sm btn-warning" onclick="return confirm(\'' . __('Login as User?') . '\');"><span class="fa fa-sign-in-alt"></span></a>';
return '<a href="'.route('admin_user_login_as', [$user->id]).'" class="btn icon-btn btn-sm btn-warning" onclick="return confirm(\''.__('Login as User?').'\');"><span class="fa fa-sign-in-alt"></span></a>';
})
->addColumn('action_delete', function (User $user) {
return '<a class="btn icon-btn btn-sm btn-danger" href="#" data-toggle="modal" data-target="#modals-user-delete" data-id="' . $user->id . '" data-email="' . $user->email . '"><span class="fa fa-trash"></span></a>';
return '<a class="btn icon-btn btn-sm btn-danger" href="#" data-toggle="modal" data-target="#modals-user-delete" data-id="'.$user->id.'" data-email="'.$user->email.'"><span class="fa fa-trash"></span></a>';
})
->addColumn('test_mode', function (User $user) {
$link = '<a href="#" data-toggle="modal" data-target="#modals-test_mode" data-id="' . $user->id . '" data-email="' . $user->email . '" data-test_mode="' . $user->test_mode . '">';
return $user->test_mode ? $link . '<span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span></a>' : $link . '<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
$link = '<a href="#" data-toggle="modal" data-target="#modals-test_mode" data-id="'.$user->id.'" data-email="'.$user->email.'" data-test_mode="'.$user->test_mode.'">';
return $user->test_mode ? $link.'<span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span></a>' : $link.'<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span></a>';
})
->orderColumn('id', 'id $1')
->orderColumn('email', 'email $1')

View file

@ -1,116 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Session;
use \SoapClient;
class KasController extends Controller
{
// Logindaten
private $kas_user = 'w017f6e4'; // KAS-Logon
private $kas_pass = 'Medxiz-funteb-7dubdi'; // KAS-Passwort
private $session_lifetime = 600; // Gültigkeit des Tokens in Sek. bis zur neuen Authentifizierung
private $session_update_lifetime = 'Y'; // bei N läuft die Session nach <$session_lifetime> 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 ) );
}
}
}

View file

@ -1,267 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
class KasSLLController extends Controller
{
private static $ssl_certificate_sni_csr = "";
private static $ssl_certificate_sni_key = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgohGr2e3ysw/Awvzh
qkqDS4iQgRvWwNIYxTcPxpdcndGhRANCAASZjlV2bQbLQrOveMlYOowR3IlfND7z
OxauFGabhvWSU1cg2w4U4bu/QXnDXfHHkcLp4M5WgHzX9Nw2m/abyJJ6
-----END PRIVATE KEY-----";
private static $ssl_certificate_sni_crt = "-----BEGIN CERTIFICATE-----
MIIEpDCCBEqgAwIBAgIQVIm0T0SQ6D20YQxMaHEKbDAKBggqhkjOPQQDAjCBjzEL
MAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
BxMHU2FsZm9yZDEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5T
ZWN0aWdvIEVDQyBEb21haW4gVmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENBMB4X
DTI0MDgwMTAwMDAwMFoXDTI1MDkwMTIzNTk1OVowGDEWMBQGA1UEAwwNKi5taXZp
dGEuY2FyZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJmOVXZtBstCs694yVg6
jBHciV80PvM7Fq4UZpuG9ZJTVyDbDhThu79BecNd8ceRwungzlaAfNf03Dab9pvI
knqjggL8MIIC+DAfBgNVHSMEGDAWgBT2hQo7EYbhBH0Oqgss0u7MZHt7rjAdBgNV
HQ4EFgQUVCkHH2AasJQFWFs63rdcb6BRvyowDgYDVR0PAQH/BAQDAgeAMAwGA1Ud
EwEB/wQCMAAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEkGA1UdIARC
MEAwNAYLKwYBBAGyMQECAgcwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdv
LmNvbS9DUFMwCAYGZ4EMAQIBMIGEBggrBgEFBQcBAQR4MHYwTwYIKwYBBQUHMAKG
Q2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb0VDQ0RvbWFpblZhbGlkYXRp
b25TZWN1cmVTZXJ2ZXJDQS5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNl
Y3RpZ28uY29tMCUGA1UdEQQeMByCDSoubWl2aXRhLmNhcmWCC21pdml0YS5jYXJl
MIIBfgYKKwYBBAHWeQIEAgSCAW4EggFqAWgAdgDd3Mo0ldfhFgXnlTL6x5/4PRxQ
39sAOhQSdgosrLvIKgAAAZEMky0iAAAEAwBHMEUCICSH9TLHP8tqMyBTBpxF1+lw
4wAnWf4E5pPJ6651S8P9AiEAkKqOQDaVdoFI1+jM28grXnG5o0vFLUwa0o49KYQ3
k+sAdgAN4fIwK9MNwUBiEgnqVS78R3R8sdfpMO8OQh60fk6qNAAAAZEMkyzbAAAE
AwBHMEUCIFJfJS4cojUm9nHQ1TVlxpFwOV7QwCj9MOfq0CCkVzsGAiEA8WQrE1ri
kJkeIVPSgUVJpIz8TKef2aR+Ivzkzon52QIAdgAS8U40vVNyTIQGGcOPP3oT+Oe1
YoeInG0wBYTr5YYmOgAAAZEMkyzBAAAEAwBHMEUCIQCH8/qTmCNea3FdBVk0c3Wu
FrvYnoQlTQaaDS/zeTxSzwIge6VO5Aeor30Wu675zBYzNsIru5gXOTl4dteBMYnC
0JswCgYIKoZIzj0EAwIDSAAwRQIhAKxmgpPqW6UAcWHCoWAPN673pBMxnCKn3vFq
wUkhGrT7AiBDUsDuMhabsGlZ10X2GXcm+1mwxdMLSDYEWiwk5fUaNA==
-----END CERTIFICATE-----";
private static $ssl_certificate_sni_bundle = "-----BEGIN CERTIFICATE-----
MIIDqDCCAy6gAwIBAgIRAPNkTmtuAFAjfglGvXvh9R0wCgYIKoZIzj0EAwMwgYgx
CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtKZXJz
ZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYDVQQD
EyVVU0VSVHJ1c3QgRUNDIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTE4MTEw
MjAwMDAwMFoXDTMwMTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAkdCMRswGQYDVQQI
ExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcTB1NhbGZvcmQxGDAWBgNVBAoT
D1NlY3RpZ28gTGltaXRlZDE3MDUGA1UEAxMuU2VjdGlnbyBFQ0MgRG9tYWluIFZh
bGlkYXRpb24gU2VjdXJlIFNlcnZlciBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABHkYk8qfbZ5sVwAjBTcLXw9YWsTef1Wj6R7W2SUKiKAgSh16TwUwimNJE4xk
IQeV/To14UrOkPAY9z2vaKb71EijggFuMIIBajAfBgNVHSMEGDAWgBQ64QmG1M8Z
wpZ2dEl23OA1xmNjmjAdBgNVHQ4EFgQU9oUKOxGG4QR9DqoLLNLuzGR7e64wDgYD
VR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMBsGA1UdIAQUMBIwBgYEVR0gADAIBgZngQwBAgEwUAYD
VR0fBEkwRzBFoEOgQYY/aHR0cDovL2NybC51c2VydHJ1c3QuY29tL1VTRVJUcnVz
dEVDQ0NlcnRpZmljYXRpb25BdXRob3JpdHkuY3JsMHYGCCsGAQUFBwEBBGowaDA/
BggrBgEFBQcwAoYzaHR0cDovL2NydC51c2VydHJ1c3QuY29tL1VTRVJUcnVzdEVD
Q0FkZFRydXN0Q0EuY3J0MCUGCCsGAQUFBzABhhlodHRwOi8vb2NzcC51c2VydHJ1
c3QuY29tMAoGCCqGSM49BAMDA2gAMGUCMEvnx3FcsVwJbZpCYF9z6fDWJtS1UVRs
cS0chWBNKPFNpvDKdrdKRe+oAkr2jU+ubgIxAODheSr2XhcA7oz9HmedGdMhlrd9
4ToKFbZl+/OnFFzqnvOhcjHvClECEQcKmc8fmA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID0zCCArugAwIBAgIQVmcdBOpPmUxvEIFHWdJ1lDANBgkqhkiG9w0BAQwFADB7
MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD
VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE
AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTE5MDMxMjAwMDAwMFoXDTI4
MTIzMTIzNTk1OVowgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5
MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBO
ZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgRUNDIENlcnRpZmljYXRpb24gQXV0
aG9yaXR5MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEGqxUWqn5aCPnetUkb1PGWthL
q8bVttHmc3Gu3ZzWDGH926CJA7gFFOxXzu5dP+Ihs8731Ip54KODfi2X0GHE8Znc
JZFjq38wo7Rw4sehM5zzvy5cU7Ffs30yf4o043l5o4HyMIHvMB8GA1UdIwQYMBaA
FKARCiM+lvEH7OKvKe+CpX/QMKS0MB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1
xmNjmjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zARBgNVHSAECjAI
MAYGBFUdIAAwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5j
b20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQG
CCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQEM
BQADggEBABns652JLCALBIAdGN5CmXKZFjK9Dpx1WywV4ilAbe7/ctvbq5AfjJXy
ij0IckKJUAfiORVsAYfZFhr1wHUrxeZWEQff2Ji8fJ8ZOd+LygBkc7xGEJuTI42+
FsMuCIKchjN0djsoTI0DQoWz4rIjQtUfenVqGtF8qmchxDM6OW1TyaLtYiKou+JV
bJlsQ2uRl9EMC5MCHdK8aXdJ5htN978UeAOwproLtOGFfy/cQjutdAFI3tZs4RmY
CV4Ks2dH/hzg1cEo70qLRDEmBDeNiXQ2Lu+lIg+DdEmSx/cQwgwp+7e9un/jX9Wf
8qn0dNW44bOwgeThpWOjzOoEeJBuv/c=
-----END CERTIFICATE-----";
public static function getApiSSLParameter(){
return [
'ssl_certificate_is_active' => "Y",
'ssl_certificate_sni_csr' => self::$ssl_certificate_sni_csr,
'ssl_certificate_sni_key' => self::$ssl_certificate_sni_key,
'ssl_certificate_sni_crt' => self::$ssl_certificate_sni_crt,
'ssl_certificate_sni_bundle' => self::$ssl_certificate_sni_bundle,
'ssl_certificate_force_https' => "Y",
];
}
public static function getPara(){
return [
"ssl_proxy" => "N",
"ssl_certificate_ip" => "N",
"ssl_certificate_sni" => "Y",
"ssl_certificate_sni_csr" => "-----BEGIN CERTIFICATE REQUEST-----\n
MIIC0DCCAbgCAQAwgYoxCzAJBgNVBAYTAkRFMQ4wDAYDVQQRDAU4Nzc1NTEPMA0G\n
A1UECAwGQmF5ZXJuMRUwEwYDVQQHDAxLaXJjaGhhc2xhY2gxEzARBgNVBAkMCkxl\n
aW5mZWxkIDIxFjAUBgNVBAoMDXJpd2EtdGVjIGUuSy4xFjAUBgNVBAMMDSoubWl2\n
aXRhLmNhcmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVOhtOTJBn\n
5V9SmHmo/EawNiO0VwHOVnnrfnaPD2A1DeKqHmAfMTaybHaCfi+mufV8veemfY1j\n
6rXq7RFU46SMBbFlfZqKS/3zb2d3yRT7OBU83PV5P8JXHrqEArlmKiOZcPoj86TT\n
Abq5wwxjFXkePzJSdOdUN/Z1E1tI8ieUQC40tpMsRvf5XOzQZousXBT1P6F9Q2Fb\n
UKEfiEBJ0wjnz74a73U7DebuYGEFPSjVjrkVB11+55y1MBkwg/6JIro+BlXorW6X\n
aifb1PKFbTFQnlC4BAKyPHxNKWZCSHgw/C3A7fBQKHM1wVhZo2BZrumdE+X1FOSc\n
WlN+M/+TyUybAgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEAJeDEZBjk9ITfZAzJ\n
LMVIsu4Cuz2YZkZY8r+Wdd8E1k0lAdcht2xY/uL91NwXl/hUJiVo4uBUFnCogc/k\n
dAxrRsrjiw8nHgfBgreGZj73S+tx00DUz1eP9uIVNzSO+aRMBHL8BvvLUR94KVSu\n
aVhy8fJESdDiF5TwZR7jPIWoU0esI1cEebFG2kS/wTSuUWxLh1ZGGuEKFETfEpOK\n
ooy0gUcHTP1NWo/vTDwdlf47t2vvZ/ZD0ursWXp6CNNZvwimHPxgSq8KKxLQyf5U\n
S/UHogxC8PbOzTJI0DutkCZO0iUO8gTq0GXZHVqkqTCixfIFeuMuL0ZvXYJVhZXP\n
4CBn5g==\n
-----END CERTIFICATE REQUEST-----
",
"ssl_certificate_sni_key" => "-----BEGIN PRIVATE KEY-----\n
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCVOhtOTJBn5V9S\n
mHmo/EawNiO0VwHOVnnrfnaPD2A1DeKqHmAfMTaybHaCfi+mufV8veemfY1j6rXq\n
7RFU46SMBbFlfZqKS/3zb2d3yRT7OBU83PV5P8JXHrqEArlmKiOZcPoj86TTAbq5\n
wwxjFXkePzJSdOdUN/Z1E1tI8ieUQC40tpMsRvf5XOzQZousXBT1P6F9Q2FbUKEf\n
iEBJ0wjnz74a73U7DebuYGEFPSjVjrkVB11+55y1MBkwg/6JIro+BlXorW6Xaifb\n
1PKFbTFQnlC4BAKyPHxNKWZCSHgw/C3A7fBQKHM1wVhZo2BZrumdE+X1FOScWlN+\n
M/+TyUybAgMBAAECggEAJ0hYj9AP44m6AiApRpbCdPiLhZmx3ANfrOJpi1dc2BqD\n
pIzCePOXlnh+6fMV0Cn7uY60QFuksLzEjsdBXLtgQYvuGu1plSZT/5VAA4RnhYpJ\n
7O+tnvFt00k/iCi/bWmCXY4kCvrEVNeLtALoa9znOVMhiBtGGiFxO3iQ+y7jxF6J\n
49O99G8gPGjMm/BdFjnBpUZ+Z5ZGXvrKTZaQRDE5HXEM8dUTBXPL4+dMdfQIiyKZ\n
pNklwkMjS4/LY6xDP16Wj25bSq5W9WSlTja/ZJ2eKqr6c7WxKP6TvjGh9FMkIUps\n
Bl9BNKmgixgiHVq/4WwUSZ1PAEuGQJiptVdeJcgioQKBgQDDdNaRg6Z5yVk+UjXw\n
DHJkUmquowijJUG/2seLYMFm1lkr9xbGvfGfnOSr79jim3haL/qichWh++QjeBsM\n
fwBPMbRY+JNMHpaDpvHAI2YNqXP+rBr4pJnICrHoqIzVqxbDJ04LQZBRD10cTlFz\n
+l+Ok60XTAX/wlKN96BnjuOVXQKBgQDDc2aoU37E4wPYNXcMLvoDv3+Zq3KCEMQD\n
gtNgSbyd37Dw8n35TGWubFLsvYnPLBebB6wAgTPzvTpJmPTr7nKUJsd4rbfvuh+i\n
vVhH/2xq70Pi1XqvQkmo+H1OJX+t2n/Hxr7TQGkqVI9eNfvW8UP+TGPjxGIw8Y0b\n
6t8Ky6USVwKBgQCszV5qVh9Xqtj4zUwch5SW93qUHVWkj2rayP0ET62NUtKRmSmM\n
2h+GAvr0u99fMR6tdZ+8AOr5RC7F4Qjg+mN2oLYWtuXbNWvSx0USnvk5+Oexb82E\n
qFnBTxtNW77vpQxByz0nnHaQA+pI/UDsLZ5P+mXco/zlypKcKyKoi97PjQKBgDQV\n
9+CZx6m+edLPhLc5eaUwDlgsaWqh/yqUXbJGVD6aUzQS22Fpa5uNAJhYdnZAYNYO\n
uFa2F9s3rWXZnkOVmvFCWFwfp2n6Zt3eqb0eI41nz+aOT5CPEMQ33GTL93ekR/M8\n
UrRHcP8347EOn9uLFjyZrPEQ773tUVaERAZDeO0nAoGAZXMhlmKmqTrM2jSb64ja\n
pEddcEW2LuTvwQueOKUuSSwmCydKXkcgrYZ4EHyOgvVN9JZ5ZfW6ZathFipVEKdy\n
diQ860kC4h++erAa8dvB1DUG5oldYYPiEKOyyyn+tNU298QcEkLrG1JcLuUXpfTg\n
8dPIr+VpGomsvpwGTfJFjlE=\n
-----END PRIVATE KEY-----\n
",
"ssl_certificate_sni_crt" => "-----BEGIN CERTIFICATE-----\n
MIIGLzCCBRegAwIBAgIRAJ6HzyfKXWCtRn3q9gGkgYEwDQYJKoZIhvcNAQELBQAw\n
gY8xCzAJBgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAO\n
BgNVBAcTB1NhbGZvcmQxGDAWBgNVBAoTD1NlY3RpZ28gTGltaXRlZDE3MDUGA1UE\n
AxMuU2VjdGlnbyBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBD\n
QTAeFw0yMTA3MjIwMDAwMDBaFw0yMjA3MjIyMzU5NTlaMBgxFjAUBgNVBAMMDSou\n
bWl2aXRhLmNhcmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVOhtO\n
TJBn5V9SmHmo/EawNiO0VwHOVnnrfnaPD2A1DeKqHmAfMTaybHaCfi+mufV8veem\n
fY1j6rXq7RFU46SMBbFlfZqKS/3zb2d3yRT7OBU83PV5P8JXHrqEArlmKiOZcPoj\n
86TTAbq5wwxjFXkePzJSdOdUN/Z1E1tI8ieUQC40tpMsRvf5XOzQZousXBT1P6F9\n
Q2FbUKEfiEBJ0wjnz74a73U7DebuYGEFPSjVjrkVB11+55y1MBkwg/6JIro+BlXo\n
rW6Xaifb1PKFbTFQnlC4BAKyPHxNKWZCSHgw/C3A7fBQKHM1wVhZo2BZrumdE+X1\n
FOScWlN+M/+TyUybAgMBAAGjggL6MIIC9jAfBgNVHSMEGDAWgBSNjF7EVK2K4Xfp\n
m/mbBeG4AY1h4TAdBgNVHQ4EFgQUCS0Y1v7p19isO7cTuP3YrKVr2OcwDgYDVR0P\n
AQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsG\n
AQUFBwMCMEkGA1UdIARCMEAwNAYLKwYBBAGyMQECAgcwJTAjBggrBgEFBQcCARYX\n
aHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQIBMIGEBggrBgEFBQcBAQR4\n
MHYwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1JT\n
QURvbWFpblZhbGlkYXRpb25TZWN1cmVTZXJ2ZXJDQS5jcnQwIwYIKwYBBQUHMAGG\n
F2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMCUGA1UdEQQeMByCDSoubWl2aXRhLmNh\n
cmWCC21pdml0YS5jYXJlMIIBfAYKKwYBBAHWeQIEAgSCAWwEggFoAWYAdQBGpVXr\n
dfqRIDC1oolp9PN9ESxBdL79SbiFq/L8cP5tRwAAAXrNeYDBAAAEAwBGMEQCIFzd\n
+zLvEGolSmSaa7vaQxv63DuX5vHQggER6/Dh+jZGAiAcUn8AZjF7GQOd4LTzGMhU\n
KsGNyn6d3n4cJ9fy9BzRxAB1AEHIyrHfIkZKEMahOglCh15OMYsbA+vrS8do8JBi\n
lgb2AAABes15gIYAAAQDAEYwRAIgE0NFzvN7qEre8Bc1C8EsMHD+5PDyQHZRBJkN\n
OdxsH9MCIDBSFFZTheD2+nzbHm5WLvAI75xyUvyBx/LEy3XBtjulAHYAKXm+8J45\n
OSHwVnOfY6V35b5XfZxgCvj5TV0mXCVdx4QAAAF6zXmAWwAABAMARzBFAiAbRPVk\n
w3AIzVF7gE0R3ZJgou7P4o9KL2yRgAaeGbbClgIhAPL86sD0GwPZ9ZsL31q07Y/S\n
1kq5ohBt907fOisMwI0HMA0GCSqGSIb3DQEBCwUAA4IBAQAaYeV2NtUM2HkxWbfd\n
3jVAs1PdBIYtktBpx7UwNphylqF4qlsZwV5XZxeD/K7mTW5tgNaHHrEjaOME/y1s\n
rWTIt1D+UUmDdiSgKfVF5gfajPFVepOcb5OC+ielevvnVJn/6Tqa/RNz0GstwMnB\n
3lBaoP7oGuBy2Ow3LG0+yO4Q0j82gIkOM15CsjY9ZK540HAXllxKGN29Yf+RDkqE\n
zRk4TE12MEW+Ugw6RxDSUCfKmev4iUAT9vq790OESAfOKY1zg/6hIF3noH1IFt1d\n
e0wVWz58KTXBqHsmxX3F1PUuT6NY+wRsVfnc8hR8mfJibJ0VL8wxjzScDXyHpZr/\n
o3I7\n
-----END CERTIFICATE-----
",
"ssl_certificate_sni_bundle" => "-----BEGIN CERTIFICATE-----\n
MIIGEzCCA/ugAwIBAgIQfVtRJrR2uhHbdBYLvFMNpzANBgkqhkiG9w0BAQwFADCB\n
iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl\n
cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV\n
BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTgx\n
MTAyMDAwMDAwWhcNMzAxMjMxMjM1OTU5WjCBjzELMAkGA1UEBhMCR0IxGzAZBgNV\n
BAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEYMBYGA1UE\n
ChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFJTQSBEb21haW4g\n
VmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC\n
AQ8AMIIBCgKCAQEA1nMz1tc8INAA0hdFuNY+B6I/x0HuMjDJsGz99J/LEpgPLT+N\n
TQEMgg8Xf2Iu6bhIefsWg06t1zIlk7cHv7lQP6lMw0Aq6Tn/2YHKHxYyQdqAJrkj\n
eocgHuP/IJo8lURvh3UGkEC0MpMWCRAIIz7S3YcPb11RFGoKacVPAXJpz9OTTG0E\n
oKMbgn6xmrntxZ7FN3ifmgg0+1YuWMQJDgZkW7w33PGfKGioVrCSo1yfu4iYCBsk\n
Haswha6vsC6eep3BwEIc4gLw6uBK0u+QDrTBQBbwb4VCSmT3pDCg/r8uoydajotY\n
uK3DGReEY+1vVv2Dy2A0xHS+5p3b4eTlygxfFQIDAQABo4IBbjCCAWowHwYDVR0j\n
BBgwFoAUU3m/WqorSs9UgOHYm8Cd8rIDZsswHQYDVR0OBBYEFI2MXsRUrYrhd+mb\n
+ZsF4bgBjWHhMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMB0G\n
A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAbBgNVHSAEFDASMAYGBFUdIAAw\n
CAYGZ4EMAQIBMFAGA1UdHwRJMEcwRaBDoEGGP2h0dHA6Ly9jcmwudXNlcnRydXN0\n
LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDB2Bggr\n
BgEFBQcBAQRqMGgwPwYIKwYBBQUHMAKGM2h0dHA6Ly9jcnQudXNlcnRydXN0LmNv\n
bS9VU0VSVHJ1c3RSU0FBZGRUcnVzdENBLmNydDAlBggrBgEFBQcwAYYZaHR0cDov\n
L29jc3AudXNlcnRydXN0LmNvbTANBgkqhkiG9w0BAQwFAAOCAgEAMr9hvQ5Iw0/H\n
ukdN+Jx4GQHcEx2Ab/zDcLRSmjEzmldS+zGea6TvVKqJjUAXaPgREHzSyrHxVYbH\n
7rM2kYb2OVG/Rr8PoLq0935JxCo2F57kaDl6r5ROVm+yezu/Coa9zcV3HAO4OLGi\n
H19+24rcRki2aArPsrW04jTkZ6k4Zgle0rj8nSg6F0AnwnJOKf0hPHzPE/uWLMUx\n
RP0T7dWbqWlod3zu4f+k+TY4CFM5ooQ0nBnzvg6s1SQ36yOoeNDT5++SR2RiOSLv\n
xvcRviKFxmZEJCaOEDKNyJOuB56DPi/Z+fVGjmO+wea03KbNIaiGCpXZLoUmGv38\n
sbZXQm2V0TP2ORQGgkE49Y9Y3IBbpNV9lXj9p5v//cWoaasm56ekBYdbqbe4oyAL\n
l6lFhd2zi+WJN44pDfwGF/Y4QA5C5BIG+3vzxhFoYt/jmPQT2BVPi7Fp2RBgvGQq\n
6jG35LWjOhSbJuMLe/0CjraZwTiXWTb2qHSihrZe68Zk6s+go/lunrotEbaGmAhY\n
LcmsJWTyXnW0OMGuf1pGg+pRyrbxmRE1a6Vqe8YAsOf4vmSyrcjC8azjUeqkk+B5\n
yOGBQMkKW+ESPMFgKuOXwIlCypTPRpgSabuY0MLTDXJLR27lk8QyKGOHQ+SwMj4K\n
00u/I5sUKUErmgQfky3xxzlIPK1aEn8=\n
-----END CERTIFICATE-----\n
-----BEGIN CERTIFICATE-----\n
MIIFgTCCBGmgAwIBAgIQOXJEOvkit1HX02wQ3TE1lTANBgkqhkiG9w0BAQwFADB7\n
MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\n
VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\n
AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTE5MDMxMjAwMDAwMFoXDTI4\n
MTIzMTIzNTk1OVowgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5\n
MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBO\n
ZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0\n
aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAgBJlFzYOw9sI\n
s9CsVw127c0n00ytUINh4qogTQktZAnczomfzD2p7PbPwdzx07HWezcoEStH2jnG\n
vDoZtF+mvX2do2NCtnbyqTsrkfjib9DsFiCQCT7i6HTJGLSR1GJk23+jBvGIGGqQ\n
Ijy8/hPwhxR79uQfjtTkUcYRZ0YIUcuGFFQ/vDP+fmyc/xadGL1RjjWmp2bIcmfb\n
IWax1Jt4A8BQOujM8Ny8nkz+rwWWNR9XWrf/zvk9tyy29lTdyOcSOk2uTIq3XJq0\n
tyA9yn8iNK5+O2hmAUTnAU5GU5szYPeUvlM3kHND8zLDU+/bqv50TmnHa4xgk97E\n
xwzf4TKuzJM7UXiVZ4vuPVb+DNBpDxsP8yUmazNt925H+nND5X4OpWaxKXwyhGNV\n
icQNwZNUMBkTrNN9N6frXTpsNVzbQdcS2qlJC9/YgIoJk2KOtWbPJYjNhLixP6Q5\n
D9kCnusSTJV882sFqV4Wg8y4Z+LoE53MW4LTTLPtW//e5XOsIzstAL81VXQJSdhJ\n
WBp/kjbmUZIO8yZ9HE0XvMnsQybQv0FfQKlERPSZ51eHnlAfV1SoPv10Yy+xUGUJ\n
5lhCLkMaTLTwJUdZ+gQek9QmRkpQgbLevni3/GcV4clXhB4PY9bpYrrWX1Uu6lzG\n
KAgEJTm4Diup8kyXHAc/DVL17e8vgg8CAwEAAaOB8jCB7zAfBgNVHSMEGDAWgBSg\n
EQojPpbxB+zirynvgqV/0DCktDAdBgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rID\n
ZsswDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wEQYDVR0gBAowCDAG\n
BgRVHSAAMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29t\n
L0FBQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggr\n
BgEFBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBDAUA\n
A4IBAQAYh1HcdCE9nIrgJ7cz0C7M7PDmy14R3iJvm3WOnnL+5Nb+qh+cli3vA0p+\n
rvSNb3I8QzvAP+u431yqqcau8vzY7qN7Q/aGNnwU4M309z/+3ri0ivCRlv79Q2R+\n
/czSAaF9ffgZGclCKxO/WIu6pKJmBHaIkU4MiRTOok3JMrO66BQavHHxW/BBC5gA\n
CiIDEOUMsfnNkjcZ7Tvx5Dq2+UUTJnWvu6rvP3t3O9LEApE9GQDTF1w52z97GA1F\n
zZOFli9d31kWTz9RvdVFGD/tSo7oBmF0Ixa1DVBzJ0RHfxBdiSprhTEUxOipakyA\n
vGp4z7h/jnZymQyd/teRCBaho1+V\n
-----END CERTIFICATE-----
",
"ssl_certificate_sni_chainfile" => null,
"ssl_certificate_sni_force_https" => "N",
"ssl_certificate_sni_hsts_max_age" => "-1"
];
}
}

View file

@ -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);

View file

@ -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 '<a href="' . route('admin_customer_detail', [$ShoppingUser->id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
return '<a href="'.route('admin_customer_detail', [$ShoppingUser->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
})
->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' ? ' <span class="badge badge-warning">dev</span>' : '');
return get_active_badge($ShoppingUser->auth_user_id).($ShoppingUser->mode === 'dev' ? ' <span class="badge badge-warning">dev</span>' : '');
})
->addColumn('member_id', function (ShoppingUser $ShoppingUser) {
if ($ShoppingUser->is_like) {
return '<button type="button" class="btn btn-xs btn-outline-info" data-toggle="modal" data-target="#modals-load-content"
data-id="' . $ShoppingUser->id . '"
data-id="'.$ShoppingUser->id.'"
data-action="shopping-user-is-like-member"
data-back="' . route('admin_customers') . '"
data-back="'.route('admin_customers').'"
data-modal="modal-xl"
data-route="' . route('modal_load') . '"><span class="fa fa-edit"></span> Berater zuordnen</button>';
data-route="'.route('modal_load').'"><span class="fa fa-edit"></span> Berater zuordnen</button>';
}
if ($ShoppingUser->member) {
return '<a href="' . route('admin_lead_edit', [$ShoppingUser->member_id]) . '">' . $ShoppingUser->member->getFullName() . '</a>';
return '<a href="'.route('admin_lead_edit', [$ShoppingUser->member_id]).'">'.$ShoppingUser->member->getFullName().'</a>';
}
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')

View file

@ -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);

View file

@ -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()

View file

@ -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' => [],

View file

@ -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' => [],

View file

@ -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;

View file

@ -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);

View file

@ -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', '', '', '', ''];
}
}

View file

@ -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;
}

View file

@ -1,22 +1,21 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Api\KasController;
use App\Models\UserShop;
use App\Models\UserShopOnSite;
use App\Repositories\UserRepository;
use App\Services\Slim;
use Auth;
use Cviebrock\EloquentSluggable\Services\SlugService;
use Request;
use Response;
use Validator;
class UserShopController extends Controller
{
protected $db;
protected $userRepo;
public function __construct(UserRepository $userRepo)
@ -28,9 +27,9 @@ class UserShopController extends Controller
public function index()
{
$user = Auth::user();
if ($user->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);
}
}

View file

@ -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<int, string>
*/
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);
}

View file

@ -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<int, \Acme\Dhl\Models\DhlShipment> $dhlOutboundShipments
* @property-read Collection<int, DhlShipment> $dhlOutboundShipments
* @property-read int|null $dhl_outbound_shipments_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Acme\Dhl\Models\DhlShipment> $dhlReturnShipments
* @property-read Collection<int, DhlShipment> $dhlReturnShipments
* @property-read int|null $dhl_return_shipments_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Acme\Dhl\Models\DhlShipment> $dhlShipments
* @property-read Collection<int, DhlShipment> $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'].'' : '';

View file

@ -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);

View file

@ -0,0 +1,127 @@
<?php
namespace App\Models;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Protokoll der Status-Aktionen eines Abos (Pause, Kündigung, Reaktivierung):
* wer (view + actor_user_id) hat wann welche Aktion ausgeführt und wie sich das
* Ausführungsdatum dadurch verschoben hat.
*
* @property int $id
* @property int $user_abo_id
* @property string $action
* @property string|null $view
* @property int|null $actor_user_id
* @property Carbon|null $previous_next_date
* @property Carbon|null $new_next_date
*/
class UserAboStatusHistory extends Model
{
public const ACTION_PAUSED = 'paused';
public const ACTION_CANCELLED = 'cancelled';
public const ACTION_DEACTIVATED = 'deactivated';
public const ACTION_REACTIVATED = 'reactivated';
public static $actionLabels = [
self::ACTION_PAUSED => '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 '<span class="badge badge-'.$color.'">'.$label.'</span>';
}
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 '<span class="badge badge-outline-'.$color.'">'.$label.'</span>';
}
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 ?? '-');
}
}

View file

@ -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;
}
}
}

View file

@ -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();

View file

@ -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) {

View file

@ -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';
}
}

View file

@ -0,0 +1,250 @@
<?php
namespace App\Services\Abo;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\SalesVolumePoints;
use App\Services\BusinessPlan\LiveCommissionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
/**
* Payline-bezogene Abo-Kennzahlen (Konzept B8 + B10):
*
* - Payline = die ersten N Ebenen der Downline; N ("paylines") kommt aus dem
* Provisionslevel des Users (Live-TreeCalcBot bzw. UserBusiness-Snapshot).
* - B8 aboTurnover(): Abo-Gesamtumsatz der Payline als Soll (Abo-Punkte-Bestand
* pro Zyklus) und Ist (im Monat abgerechnete Abo-Punkte, F10).
* - B10 forecastDays(): voraussichtlicher Payline-Gesamtumsatz zu den festen
* Ausführungsterminen 5./10./20./25. — Basis (Kundenumsatz + Payline-Punkte
* Stand heute) plus die bis zum jeweiligen Termin noch anstehenden
* Abo-Punkte, kumuliert (F11).
*/
class PaylineForecastService
{
/**
* F12 (dokumentierte Annahme, Kunde entscheidet nach Umsetzung): Die eigenen
* Abos und eigenen Kundenabos des Beraters zählen in der Prognose MIT, weil
* die Basis der Formel den aktuellen Kundenumsatz enthält und kommende
* eigene Ausführungen genau diesen Anteil erhöhen. Über diesen Schalter
* ohne Umbau umkehrbar.
*/
public const INCLUDE_OWN_ABOS = true;
private const CACHE_HOURS = 6;
public function __construct(
private BackofficeDashboardService $dashboardService,
private LiveCommissionService $commissionService,
) {}
/**
* Payline-Kontext (Anzahl bezahlter Ebenen + Mitglieder-IDs), 6 h gecacht —
* die Payline-Tiefe kommt aus dem gemeinsamen Live-Lauf, die Abo-Summen
* bleiben live. Ein fehlgeschlagener Live-Lauf wird nicht gecacht, damit
* ein transienter Fehler die Karte nicht 6 h lang leert.
*
* @return array{paylines: int, member_ids: int[]}
*/
public function paylineContext(User $user, int $month, int $year): array
{
$cacheKey = "payline_context_{$user->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<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
*/
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));
});
}
}

View file

@ -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<int, \App\User> $users
* @param Collection<int, User> $users
* @return array<string, mixed>
*/
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<int, \App\Models\UserAbo>
* @return Collection<int, UserAbo>
*/
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<int, \Illuminate\Support\Collection<int, \App\Models\UserAbo>>
* @return Collection<int, Collection<int, UserAbo>>
*/
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];
}

View file

@ -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.

View file

@ -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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $overview
* @return array<string, mixed>
@ -123,7 +211,7 @@ class BackofficeDashboardService
}
/**
* @return array<int, \Illuminate\Support\Collection<int, \App\User>>
* @return array<int, Collection<int, User>>
*/
public function lineBuckets(int $rootUserId): array
{
@ -206,7 +294,7 @@ class BackofficeDashboardService
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
* @param Collection<int, User> $users
* @return array<string, mixed>
*/
private function buildLineRow(int $line, Collection $users, int $month, int $year): array
@ -286,7 +374,7 @@ class BackofficeDashboardService
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
* @param Collection<int, User> $users
*/
private function activeConsultants(Collection $users): int
{
@ -296,7 +384,7 @@ class BackofficeDashboardService
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
* @param Collection<int, User> $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<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, abo_count: int, total_abo_count: int}>
* @param array<int, Collection<int, User>> $lineBuckets
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, is_self: bool, abo_count: int, total_abo_count: int}>
*/
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<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
* @return array{level_name: string, total: int, total_own: int, total_customer: int, rows: array<int, array{user_id: int, name: string, career_level: string, line: int, own_abo_count: int, customer_abo_count: int}>}
* 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<int, Collection<int, User>> $lineBuckets
* @return array{level_name: string, total: int, total_own: int, total_customer: int, total_team: int, total_team_customer: int, rows: array<int, array{user_id: int, name: string, career_level: string, line: int, own_abo_count: int, customer_abo_count: int, team_abo_count: int, team_customer_abo_count: int, team_abo_count_total: int, team_customer_abo_count_total: int, total_abo_count: int, total_abo_count_full: int}>}
*/
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<int, array{user: User, line: int}> $usersWithLine
* @param int[] $leaderIds
* @return array<int, int[]>
*/
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<int, Collection<int, User>> $lineBuckets
* @return array{by_count: array<int, array<string, mixed>>, by_points: array<int, array<string, mixed>>}
*/
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<int, float>
*/
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<int, float>
*/
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<int, int>
* @return Collection<int, int>
*/
private function customerAboCounts(array $memberIds): Collection
{
@ -522,13 +817,20 @@ class BackofficeDashboardService
}
/**
* @param array<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
* @return array<int, array{user: \App\User, line: int}>
* Downline-User mit ihrer Linie; optional inklusive des Statistik-Nutzers
* selbst als Linie 0 (er steckt in keinem lineBucket, {@see lineBuckets()}).
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array<int, array{user: User, line: int}>
*/
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];

View file

@ -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<int, array<string, mixed>>, summary: array<string, mixed>}
* @return array{metric: string, metric_label: string, only_new: bool, line: int, line_label: string, month: int, year: int, rows: array<int, array<string, mixed>>, summary: array<string, mixed>}
*/
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<int, array<string, mixed>> $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<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $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<int, float>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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 ?? ''));

View file

@ -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');

View file

@ -0,0 +1,145 @@
<?php
namespace App\Services\BusinessPlan;
use App\Models\UserBusiness;
use App\User;
use Illuminate\Support\Facades\Cache;
/**
* Gemeinsamer Live-Lauf für die "Stand heute"-Kennzahlen (Konzept B1/B8/B10):
* EIN TreeCalcBotOptimized-Lauf pro User/Monat liefert Payline-Punkte,
* Provisions-Bestandteile und die Payline-Tiefe, 6 h gecacht (F5).
*
* - Liegt für den Monat bereits ein UserBusiness-Datensatz vor (Monats-Batch
* `business:store-optimized` gelaufen), gelten dessen Werte als final.
* - Sonst rechnet TreeCalcBotOptimized live — dieselbe Engine wie der
* Monats-Batch (inkl. GrowthBonusCalculator und korrigierter Level-Auswahl),
* damit Live- und Finalwerte nicht systematisch auseinanderlaufen.
* - Fehlgeschlagene Läufe werden NICHT gecacht, damit ein transienter Fehler
* nicht 6 h lang Nullwerte anzeigt (`failed` markiert diesen Fall).
*/
class LiveCommissionService
{
private const CACHE_HOURS = 6;
/**
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool}
*/
public function snapshot(User $user, int $month, int $year): array
{
$stored = UserBusiness::where('user_id', $user->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<string, mixed>|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,
];
}
}

View file

@ -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<int, array{own: bool, customer_count: int}>
*/
private array $aboBadges = [];
public function __construct(string $initFrom = 'member', bool $forceLiveCalculation = false)
{
$this->initFrom = $initFrom;
@ -28,6 +38,8 @@ class TreeHtmlRenderer
return '<div class="alert alert-info">Keine Business-User gefunden.</div>';
}
$this->loadAboBadges($this->collectUserIds($businessUsers));
$html = '<ol class="dd-list">';
foreach ($businessUsers as $businessUser) {
$html .= $this->renderUserItem($businessUser, 0);
@ -46,6 +58,8 @@ class TreeHtmlRenderer
return '<div class="alert alert-info">Keine parentlosen User gefunden.</div>';
}
$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 '<div class="alert alert-warning">' . __('team.no_sponsor_assigned') . '</div>';
if (! $sponsor) {
return '<div class="alert alert-warning">'.__('team.no_sponsor_assigned').'</div>';
}
return '<li class="dd-item dd-nodrag" data-id="">' .
'<div class="dd-handle">' .
$this->renderUserInfo($sponsor, false, true) .
'</div>' .
return '<li class="dd-item dd-nodrag" data-id="">'.
'<div class="dd-handle">'.
$this->renderUserInfo($sponsor, false, true).
'</div>'.
'</li>';
}
@ -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 '<div class="alert alert-info">Kein Sponsor gefunden.</div>';
}
@ -103,19 +117,19 @@ class TreeHtmlRenderer
// Sponsor Info
$html .= '<div class="col-md-3">';
$html .= '<strong>' . e($sponsor->account->first_name . ' ' . $sponsor->account->last_name) . '</strong><br>';
$html .= '<small>' . e($sponsor->email) . '</small>';
$html .= '<strong>'.e($sponsor->account->first_name.' '.$sponsor->account->last_name).'</strong><br>';
$html .= '<small>'.e($sponsor->email).'</small>';
$html .= '</div>';
// Account Info
$html .= '<div class="col-md-2">';
$html .= '<span class="badge badge-secondary">' . e($sponsor->account->m_account ?? '') . '</span>';
$html .= '<span class="badge badge-secondary">'.e($sponsor->account->m_account ?? '').'</span>';
$html .= '</div>';
// Level Info
$html .= '<div class="col-md-2">';
if ($sponsor->user_level) {
$html .= '<span class="badge badge-primary">' . e($sponsor->user_level->getLang('name')) . '</span>';
$html .= '<span class="badge badge-primary">'.e($sponsor->user_level->getLang('name')).'</span>';
}
$html .= '</div>';
@ -136,7 +150,7 @@ class TreeHtmlRenderer
*/
private function renderTeamMemberItem($member, int $depth): string
{
$html = '<li class="dd-item" data-id="' . ($member->user_id ?? 0) . '">';
$html = '<li class="dd-item" data-id="'.($member->user_id ?? 0).'">';
$html .= '<div class="dd-handle">';
$html .= '<div class="row">';
@ -146,19 +160,19 @@ class TreeHtmlRenderer
// Name und Email
$html .= '<div class="col-md-3">';
$html .= $indent;
$html .= '<strong>' . e(($member->first_name ?? '') . ' ' . ($member->last_name ?? '')) . '</strong><br>';
$html .= $indent . '<small>' . e($member->email ?? '') . '</small>';
$html .= '<strong>'.e(($member->first_name ?? '').' '.($member->last_name ?? '')).'</strong><br>';
$html .= $indent.'<small>'.e($member->email ?? '').'</small>';
$html .= '</div>';
// Account ID
$html .= '<div class="col-md-2">';
$html .= '<span class="badge badge-secondary">' . e($member->m_account ?? '') . '</span>';
$html .= '<span class="badge badge-secondary">'.e($member->m_account ?? '').'</span>';
$html .= '</div>';
// Level
$html .= '<div class="col-md-2">';
if (!empty($member->user_level_name)) {
$html .= '<span class="badge badge-primary">' . e($member->user_level_name) . '</span>';
if (! empty($member->user_level_name)) {
$html .= '<span class="badge badge-primary">'.e($member->user_level_name).'</span>';
if ($member->next_qual_user_level) {
$html .= '<span class="badge badge-outline-success ml-2"><i class="fa fa-arrow-up text-success" title="Karriere-Level erreicht!"></i></span>';
@ -168,12 +182,12 @@ class TreeHtmlRenderer
// Qualifikation
$html .= '<div class="col-md-2">';
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 .= '<span class="badge ' . $badgeClass . '">KU ' . $qualKP . '</span>';
$html .= '<span class="badge '.$badgeClass.'">KU '.$qualKP.'</span>';
}
$html .= '</div>';
@ -186,7 +200,7 @@ class TreeHtmlRenderer
$html .= '</div>';
// Kinder rendern
if (!empty($member->businessUserItems) && is_array($member->businessUserItems)) {
if (! empty($member->businessUserItems) && is_array($member->businessUserItems)) {
$html .= '<ol class="dd-list">';
foreach ($member->businessUserItems as $child) {
$html .= $this->renderTeamMemberItem($child, $depth + 1);
@ -213,11 +227,11 @@ class TreeHtmlRenderer
$childrenHtml .= '</ol>';
}
return '<li class="dd-item dd-nodrag" data-id="' . $item->user_id . '">' .
'<div class="dd-handle">' .
$this->renderUserCardWithDepth($item, $deep) .
'</div>' .
$childrenHtml .
return '<li class="dd-item dd-nodrag" data-id="'.$item->user_id.'">'.
'<div class="dd-handle">'.
$this->renderUserCardWithDepth($item, $deep).
'</div>'.
$childrenHtml.
'</li>';
}
@ -226,10 +240,10 @@ class TreeHtmlRenderer
*/
private function renderParentlessItem($item): string
{
return '<li class="dd-item dd-nodrag" data-id="' . $item->user_id . '">' .
'<div class="dd-handle">' .
$this->renderUserInfo($item, true, false) .
'</div>' .
return '<li class="dd-item dd-nodrag" data-id="'.$item->user_id.'">'.
'<div class="dd-handle">'.
$this->renderUserInfo($item, true, false).
'</div>'.
'</li>';
}
@ -240,16 +254,16 @@ class TreeHtmlRenderer
{
$depthBadge = '';
if ($deep > 0) {
$depthBadge = '<div class="d-flex flex-column justify-content-center align-items-center">' .
'<div class="text-large font-weight-bolder line-height-1 my-2 text-secondary badge badge-outline-secondary">' . $deep . '</div>' .
$depthBadge = '<div class="d-flex flex-column justify-content-center align-items-center">'.
'<div class="text-large font-weight-bolder line-height-1 my-2 text-secondary badge badge-outline-secondary">'.$deep.'</div>'.
'</div>';
}
return '<div class="media align-items-center">' .
$depthBadge .
'<div class="media-body ml-2">' .
$this->renderUserInfo($item, false, false) .
'</div>' .
return '<div class="media align-items-center">'.
$depthBadge.
'<div class="media-body ml-2">'.
$this->renderUserInfo($item, false, false).
'</div>'.
'</div>';
}
@ -263,35 +277,35 @@ class TreeHtmlRenderer
\Log::debug("TreeHtmlRenderer: Rendering user info for user {$item->user_id}");
$html = '<span class="' . $statusClass . '">';
$html = '<span class="'.$statusClass.'">';
// User Link
$html .= '<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" ' .
'data-id="' . $item->user_id . '" data-action="business-user-show" data-back="" ' .
'data-modal="modal-md" data-init_from="' . $this->initFrom . '" data-route="' . route('modal_load') . '">' .
'<span class="mr-1 ion ion-ios-contact ' . $iconClass . '"></span> ' .
'<strong>' . e($item->first_name . ' ' . $item->last_name) . '</strong>' .
$html .= '<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" '.
'data-id="'.$item->user_id.'" data-action="business-user-show" data-back="" '.
'data-modal="modal-md" data-init_from="'.$this->initFrom.'" data-route="'.route('modal_load').'">'.
'<span class="mr-1 ion ion-ios-contact '.$iconClass.'"></span> '.
'<strong>'.e($item->first_name.' '.$item->last_name).'</strong>'.
'</a>';
// Email
$html .= ' <a href="mailto:' . e($item->email) . '">' . e($item->email) . '</a>';
$html .= ' <a href="mailto:'.e($item->email).'">'.e($item->email).'</a>';
// Optional: Geburtstag
$birthday = $item->user_birthday; // Magic Method __get() verwenden
if ($birthday && trim($birthday) !== '') {
$html .= ' | <i class="ion ion-ios-gift text-primary"></i> ' . e($birthday);
$html .= ' | <i class="ion ion-ios-gift text-primary"></i> '.e($birthday);
}
// Optional: Telefon
$phone = $item->user_phone; // Magic Method __get() verwenden
if ($phone && trim($phone) !== '') {
$html .= ' | <i class="ion ion-ios-call text-primary"></i> ' . e($phone);
$html .= ' | <i class="ion ion-ios-call text-primary"></i> '.e($phone);
}
// Level Badge
$levelName = $item->user_level_name ? TranslationHelper::transUserLevelName($item->user_level_name) : '';
$account = $item->m_account ?: '';
$html .= ' <span class="badge badge-outline-default ' . $statusClass . '">' . e($levelName . ' | ' . $account) . '</span>';
$html .= ' <span class="badge badge-outline-default '.$statusClass.'">'.e($levelName.' | '.$account).'</span>';
// Karriere-Aufstiegs-Icon für qualifizierte User (nur in Struktur-Ansicht)#
@ -299,15 +313,18 @@ class TreeHtmlRenderer
$html .= '<span class="badge badge-outline-success ml-2"><i class="fa fa-arrow-up text-success" title="Karriere-Level erreicht!"></i></span>';
}
// Abo-Kennzeichen (eigenes Abo + eigene Kundenabos)
$html .= $this->renderAboBadges((int) $item->user_id);
// Details für aktive Accounts
if ($item->active_account) {
$html .= '<br><span class="small">';
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 .= '<br><span class="small">' . __('team.account_to') . ': ' . e($paymentDate) . '</span>';
$html .= '<br><span class="small">'.__('team.account_to').': '.e($paymentDate).'</span>';
}
// Sponsor für parentlose User
if ($showSponsor && $item->m_sponsor_name) {
$html .= '<br>' . e($item->m_sponsor_name);
$html .= '<br>'.e($item->m_sponsor_name);
}
$html .= '</span>';
@ -341,12 +358,12 @@ class TreeHtmlRenderer
$eSum = $item->sales_volume_total ?: 0;
$sSum = $item->sales_volume_total_shop ?: 0;
return '<strong>' . __('team.total_points') . ': ' . formatNumber($totalPoints) . '</strong> | ' .
__('team.e') . ': ' . formatNumber($ePoints) . ' | ' .
__('team.s') . ': ' . formatNumber($sPoints) . ' <strong> | ' .
__('team.net_turnover') . ': ' . formatNumber($totalSum) . ' &euro;</strong> | ' .
__('team.e') . ': ' . formatNumber($eSum) . ' &euro; | ' .
__('team.s') . ': ' . formatNumber($sSum) . ' &euro;';
return '<strong>'.__('team.total_points').': '.formatNumber($totalPoints).'</strong> | '.
__('team.e').': '.formatNumber($ePoints).' | '.
__('team.s').': '.formatNumber($sPoints).' <strong> | '.
__('team.net_turnover').': '.formatNumber($totalSum).' &euro;</strong> | '.
__('team.e').': '.formatNumber($eSum).' &euro; | '.
__('team.s').': '.formatNumber($sSum).' &euro;';
}
/**
@ -354,15 +371,15 @@ class TreeHtmlRenderer
*/
private function renderActionButton(int $userId): string
{
return ' | <button type="button" class="btn icon-btn btn-xs btn-secondary" ' .
'data-toggle="modal" data-target="#modals-load-content" ' .
'data-id="' . $userId . '" data-action="business-user-detail" ' .
'data-back="" data-modal="modal-xl" ' .
'data-init_from="' . $this->initFrom . '" ' .
'data-live="' . $this->forceLiveCalculation . '" ' .
'data-optimized="1" ' .
'data-route="' . route('modal_load') . '">' .
'<span class="fa fa-calculator"></span>' .
return ' | <button type="button" class="btn icon-btn btn-xs btn-secondary" '.
'data-toggle="modal" data-target="#modals-load-content" '.
'data-id="'.$userId.'" data-action="business-user-detail" '.
'data-back="" data-modal="modal-xl" '.
'data-init_from="'.$this->initFrom.'" '.
'data-live="'.$this->forceLiveCalculation.'" '.
'data-optimized="1" '.
'data-route="'.route('modal_load').'">'.
'<span class="fa fa-calculator"></span>'.
'</button>';
}
@ -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<int> $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 .= ' <span class="badge badge-success" title="'.e(__('team.badge_own_abo')).'">A</span>';
}
if ($customerAboCount > 0) {
$html .= ' <span class="badge badge-danger" title="'.
e(trans_choice('team.badge_customer_abos', $customerAboCount)).'">('.$customerAboCount.')</span>';
}
return $html;
}
/**
* Sammelt rekursiv alle User-IDs aus den Business-User-Items.
*
* @return array<int>
*/
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;
}
}

View file

@ -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)

View file

@ -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);
}
/**

View file

@ -0,0 +1,353 @@
<?php
namespace App\Services;
use App\Exceptions\GdprDeletionException;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserBusiness;
use App\Models\UserShop;
use App\Models\UserUpdateEmail;
use App\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* DSGVO-Löschung (Art. 17 DSGVO) für Berater (User) und Endkunden (ShoppingUser).
*
* Personenbezogene Daten werden unwiderruflich anonymisiert. Daten, die unter
* gesetzliche Aufbewahrungspflichten fallen (Rechnungen, Gutschriften,
* DATEV-Exporte, Provisionsabrechnungen, Bestellungen mit Beleg), bleiben
* gemäß Art. 17 Abs. 3 lit. b DSGVO erhalten:
* - Rechnungs-/Gutschrift-PDFs und deren DB-Zeilen werden nicht verändert.
* - Bei Kunden-Datensätzen (shopping_users) mit Bestellungen bleiben Name und
* Rechnungsadresse erhalten (Buchungsbeleg), Kontaktdaten werden entfernt.
* - Provisions-Snapshots (user_businesses) behalten die Zahlen, die
* denormalisierten Personendaten werden anonymisiert.
*/
class GdprDeletionService
{
public const ANONYMIZED_FIRSTNAME = 'Gelöscht';
public const ANONYMIZED_LASTNAME = 'DSGVO';
/**
* Berater (User) DSGVO-konform löschen.
*
* @return array<string, int|string> 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<string, int|string>
*
* @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<int, ShoppingUser>
*/
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<int, ShoppingUser> $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<string, int|string> $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));
}
}

View file

@ -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();

View file

@ -1,198 +0,0 @@
<?php
namespace App\Services\SyS;
use Request;
use App\Models\UserShop;
use App\Http\Controllers\Api\KasController;
use App\Http\Controllers\Api\KasSLLController;
class DomainSSL
{
public static function show()
{
$user_shops = UserShop::limit(1000)->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;
}
}

View file

@ -1,17 +1,13 @@
<?php
namespace App\Services\SyS;
use Request;
use App\Services\Shop;
use App\Models\UserShop;
use App\Models\ShoppingUser;
use App\Services\ShoppingUserService;
use App\Http\Controllers\Api\KasController;
use App\Http\Controllers\Api\KasSLLController;
use Request;
class ShoppingOrders
{
public static function show()
{
abort(403, 'STOP funtion not online');
@ -21,32 +17,31 @@ class ShoppingOrders
'values' => $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();
}
}

View file

@ -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 [

View file

@ -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

View file

@ -0,0 +1,173 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Zentrale Prüfung von Umsatzsteuer-Identifikationsnummern (USt-ID).
*
* - Formatprüfung (EU-Länderpräfix + Nummernteil)
* - Deutsche USt-IDs qualifizieren nie für Reverse-Charge / steuerfreie igL
* - Live-Prüfung gegen den EU-VIES-Dienst (mit Cache, da VIES langsam/instabil ist)
*/
class VatIdService
{
public const CHECK_VALID = 'valid';
public const CHECK_INVALID = 'invalid';
public const CHECK_UNAVAILABLE = 'unavailable';
private const VIES_WSDL = 'https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl';
/**
* VIES-Ländercodes (EL = Griechenland, XI = Nordirland).
*
* @var array<int, string>
*/
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;
}
}

View file

@ -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);