mivita/app/Services/Payment.php
Kevin d65922adfe Statistiken: Kundenfeedback 2026-08-19 komplett (N1-N8) + Review-Fixes + VP-/Incentive-Repair gehärtet
Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen):
- N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch
- N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell)
- N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt'
- N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände)
- N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung)
- N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter)
- N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix
- N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav)

Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types

Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:44:44 +00:00

367 lines
15 KiB
PHP

<?php
namespace App\Services;
use App\Mail\MailCheckout;
use App\Models\ProductBuying;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\UserCreditItem;
use App\Models\UserLevel;
use App\Repositories\InvoiceRepository;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\Incentive\IncentiveTracker;
use App\User;
use Illuminate\Support\Facades\Mail;
class Payment
{
public static $txaction_text = [
'paid' => 'paid',
'appointed' => 'open',
'failed' => 'failed',
'extern' => 'open', // offen
'extern_paid' => 'paid',
'invoice_open' => 'open',
'invoice_paid' => 'paid',
'invoice_non' => 'no_payment',
'cancelled' => 'cancelled',
'NULL' => 'no_payment',
];
public static $txaction_filter_text = [
'paid' => 'paymend_paid',
'appointed' => 'paymend_open',
'failed' => 'paymend_failed',
'extern' => 'extern_open', // offen
'extern_paid' => 'extern_paid',
'invoice_open' => 'invoice_open',
'invoice_paid' => 'invoice_paid',
'invoice_non' => 'invoice_no_payment',
'cancelled' => 'cancelled',
'NULL' => 'no_payment',
];
public static $txaction_invoice = [
'invoice_open' => 'invoice_open',
'invoice_paid' => 'invoice_paid',
'invoice_non' => 'no_payment',
];
public static $txaction_color = [
'paid' => 'success',
'appointed' => 'warning',
'failed' => 'danger',
'extern' => 'warning',
'extern_paid' => 'success',
'invoice_open' => 'warning',
'invoice_paid' => 'success',
'invoice_non' => 'failed',
'cancelled' => 'danger',
];
public static function getFormattedTxaction($txaction)
{
if ($txaction && isset(self::$txaction_text[$txaction])) {
return __('payment.'.self::$txaction_text[$txaction]);
}
return __('payment.'.self::$txaction_text['NULL']);
}
public static function getFormattedTxactionColor($txaction)
{
if ($txaction && isset(self::$txaction_color[$txaction])) {
return self::$txaction_color[$txaction];
}
return 'warning';
}
public static function getTransTxactionFilterText()
{
$ret = [];
foreach (self::$txaction_filter_text as $key => $val) {
$ret[$key] = trans('payment.'.$val);
}
return $ret;
}
public static function getTransTxactionInvoice()
{
$ret = [];
foreach (self::$txaction_invoice as $key => $val) {
$ret[$key] = trans('payment.'.$val);
}
return $ret;
}
public static function getShoppingOrderBadge(ShoppingOrder $shopping_order)
{
if ($shopping_order->mode === 'test') {
return '<span class="badge badge-pill badge-default">'.strtoupper($shopping_order->mode).' - '.self::getFormattedTxaction($shopping_order->txaction).'</span>';
}
if ($shopping_order->mode === 'dev') {
return '<span class="badge badge-pill badge-info">'.strtoupper($shopping_order->mode).' - '.self::getFormattedTxaction($shopping_order->txaction).'</span>';
}
return '<span class="badge badge-pill badge-'.self::getFormattedTxactionColor($shopping_order->txaction).'">'.self::getFormattedTxaction($shopping_order->txaction).'</span>';
}
public static function getPaymentForBadge(ShoppingOrder $shopping_order)
{
$abo = '';
if ($shopping_order->is_abo) {
$abo = ' <span class="badge badge-pill badge-success">'.__('abo.abo').'</span>';
}
return '<span class="badge badge-pill badge-'.$shopping_order->getPaymentForColor().'">'.$shopping_order->getPaymentForType().'</span>'.$abo;
}
public static function getShoppingPaymentBadge(ShoppingPayment $shopping_payment)
{
if ($shopping_payment->mode === 'test') {
return '<span class="badge badge-pill badge-default">'.strtoupper($shopping_payment->mode).' - '.self::getFormattedTxaction($shopping_payment->txaction).'</span>';
}
return '<span class="badge badge-pill badge-'.self::getFormattedTxactionColor($shopping_payment->txaction).'">'.self::getFormattedTxaction($shopping_payment->txaction).'</span>';
}
public static function addUserCreditMargin(User $user, $credit, $status, $message)
{
UserCreditItem::create([
'user_id' => $user->id,
'credit' => $credit,
'message' => $message,
'from_month' => date('n'),
'from_year' => date('Y'),
'status' => $status,
]);
}
public static function addBuyingRestriction(User $user, $product_id)
{
ProductBuying::create([
'user_id' => $user->id,
'product_id' => $product_id,
'amount' => 1,
]);
}
public static function addSponsorBuyingPoints(User $user, $product, ?ShoppingOrder $shopping_order = null)
{
if ($user->user_sponsor) {
$data = [
'user_id' => $user->user_sponsor->id,
// Order-Referenz macht die Buchung eindeutig identifizierbar —
// der Nacht-Repair dedupliziert darüber statt über den info-Text.
'shopping_order_id' => $shopping_order?->id,
'total_net' => 0,
'points' => $product->sponsor_buying_points_amount,
'info' => 'VP: '.$user->getFullName(false).' | '.$product->name,
'status_points' => 2,
'status' => 5,
];
SalesPointsVolume::addSalesPointsVolume($data);
}
}
public static function updateUserLevel(User $user, $to_level_id)
{
// nur updaten, wenn der user->m_level kleiner ist als $to_level_id
if ($user->user_level) {
$ToUserLevel = UserLevel::find($to_level_id);
if ($user->user_level->pos < $ToUserLevel->pos) {
$user->m_level = $to_level_id;
}
} else {
$user->m_level = $to_level_id;
}
$user->save();
}
/*
Wir bei Zahlung aufgerufen.
Betätigung durch Payone oder Zahlung auf MIVITA Rechnung
$paid = Status der Zahlung, Payone = true, MIVITA Rechnung = false damit kann später die rechnung auf bezahlt gesetzt werden.
*/
public static function paymentStatusPaidAction(ShoppingOrder $shopping_order, $paid, $shopping_payment = null)
{
$send_link = false;
$shopping_order->setUserHistoryValue(['status' => 8]);
ShoppingUserService::snycOrdersByShoppingOrder($shopping_order);
$shopping_order->paid = $paid;
$shopping_order->save();
// if product has actions
if ($shopping_order->shopping_order_items && $shopping_order->auth_user_id) {
foreach ($shopping_order->shopping_order_items as $shopping_order_item) {
if ($shopping_order_item->product) {
$user = User::findOrFail($shopping_order->auth_user_id);
$user->save();
if ($shopping_order_item->product->buying_restriction) {
self::addBuyingRestriction($user, $shopping_order_item->product->id);
}
if ($shopping_order_item->product->sponsor_buying_points) {
self::addSponsorBuyingPoints($user, $shopping_order_item->product, $shopping_order);
}
if ($shopping_order_item->product->action) {
$send_link = true;
// new date
$date = \Carbon::now()->modify('1 year');
if ($user->payment_account && $user->daysActiveAccount() > 0) {
$date = \Carbon::parse($user->payment_account)->modify('1 year');
}
foreach ($shopping_order_item->product->action as $do) {
// bzw. product_id 34 = 0 => payment_for_account => payment_order_id = 35 = 0 => payment_for_account, 1 => payment_for_shop, 2 => payment_for_shop_upgrade
// 0 => payment_for_account, 1 => payment_for_shop, 2 => payment_for_shop_upgrade
if ($shopping_order_item->product->getActionName($do) === 'payment_for_account') {
$user->payment_order_id = $shopping_order_item->product->id;
$user->payment_account = $date;
$user->wizard = 100;
// only date is > now and acount is deactive.
if ($date > \Carbon::now()) {
if ($user->active === 0) {
$user->active = true;
UserUtil::reactiveUserResetChilds($user->id, 'on payment_for_account Payment');
}
}
$shopping_order->setUserHistoryValue(['status' => 9]);
}
// 1 => payment_for_shop
if ($shopping_order_item->product->getActionName($do) === 'payment_for_shop') {
$user->payment_order_id = $shopping_order_item->product->id; // 35
$user->payment_shop = $date;
$user->wizard = 100;
$shopping_order->setUserHistoryValue(['status' => 9]);
}
// 2 => payment_for_shop_upgrade
if ($shopping_order_item->product->getActionName($do) === 'payment_for_shop_upgrade') {
if ($shopping_order_item->product->upgrade_to_id) {
$user->payment_order_id = $shopping_order_item->product->upgrade_to_id;
}
$user->payment_shop = $user->payment_account; // same Date, is upgrade
$shopping_order->setUserHistoryValue(['status' => 9]);
}
// 4 => payment_for_lead_upgrade
if ($shopping_order_item->product->getActionName($do) === 'payment_for_lead_upgrade') {
if ($shopping_order_item->product->upgrade_to_id) {
self::updateUserLevel($user, $shopping_order_item->product->upgrade_to_id);
}
}
$user->save();
}
}
}
}
}
if ($shopping_order->homeparty) {
$shopping_order->setUserHistoryValue(['status' => 9]);
$shopping_order->homeparty->completed = 1;
$shopping_order->homeparty->save();
}
if ($shopping_order->shopping_collect_order) {
$shopping_order->setUserHistoryValue(['status' => 9]);
ShopApiOrderCart::finishOrder($shopping_order->shopping_collect_order);
}
// Set payment link status to paid for all orders
if ($shopping_payment) {
Util::setInstanceStatusByPayment($shopping_payment, 10); // link_paid
$shopping_payment->identifier = null;
$shopping_payment->save();
}
// the Order is Pay, so we can set the Status in the Abo
if ($shopping_order->is_abo) {
// Payone-Server-Callback kann vor dem Checkout-Erfolgs-Redirect laufen; dann existiert
// noch kein UserAbo/UserAboOrder — setAboActive wirkt erst nach Anlage.
if ($paid && $shopping_payment) {
$shopping_payment->loadMissing([
'payment_transactions',
'shopping_order.shopping_user',
'shopping_order.shopping_order_items',
]);
if ($shopping_order->recovery_user_abo_id) {
// Recovery-Zahlung: bestehendes Abo fortschreiben statt neues anlegen
// (vorher lief das nur im Browser-Return — der Webhook/Callback kann schneller sein).
AboHelper::advanceAboAfterRecoveryPayment($shopping_payment);
$shopping_order->refresh();
} elseif (! $shopping_order->getUserAbo()) {
AboHelper::createNewAbo($shopping_payment);
$shopping_order->refresh();
}
}
AboHelper::setAboActive($shopping_order, 2, true);
// Incentive: Track activated customer abo
IncentiveTracker::trackAboActivated($shopping_order);
}
// Incentive: Track new partner registration (ggf. mit Starterpaket)
if ($shopping_order->payment_for == 1) {
IncentiveTracker::trackNewPartner($shopping_order);
}
// make Invoice is not exist and is live
// Wrapped in try/catch: Rechnungserstellung darf den Payment-Flow nicht crashen
if ($shopping_order->mode === 'live' || Util::isTestSystem(true)) {
// Reload the shopping order to check for invoice again (defense against race conditions)
$shopping_order->refresh();
if (! $shopping_order->isInvoice()) {
try {
$invoice_repo = new InvoiceRepository($shopping_order);
$invoice_repo->createAndSalesVolume();
} catch (\Throwable $e) {
\Log::error('Payment::paymentStatusPaidAction - Rechnungserstellung fehlgeschlagen', [
'shopping_order_id' => $shopping_order->id,
'error' => $e->getMessage(),
]);
}
}
}
return $send_link;
}
public static function paymentStatusSendMail(ShoppingOrder $shopping_order, $shopping_payment, $data)
{
$bcc = [];
$billing_email = $shopping_order->shopping_user->billing_email;
// Überprüfung der Billing-E-Mail-Adresse
if (! $billing_email) {
if ($data['mode'] === 'test') {
$billing_email = config('app.checkout_test_mail');
} else {
$billing_email = config('app.checkout_mail');
}
}
if (! filter_var($billing_email, FILTER_VALIDATE_EMAIL)) {
\Log::channel('payment')->error('Invalid billing email at shopping_order '.$shopping_order->id, ['billing_email' => $billing_email]);
$billing_email = config('app.checkout_mail');
}
if ($data['mode'] === 'test') {
$bcc[] = config('app.checkout_test_mail');
} else {
$bcc[] = config('app.checkout_mail');
}
if (! $shopping_order->shopping_user->is_like && $shopping_order->shopping_user->member) {
$bcc[] = $shopping_order->shopping_user->member->email;
}
$data['payment_error'] = isset($data['payment_error']) ? $data['payment_error'] : false;
Mail::to($billing_email)->bcc($bcc)->locale($shopping_order->getLocale())->send(new MailCheckout($data['txaction'], $shopping_order, $shopping_payment, $data['send_link'], $data['mode'], $data['payment_error']));
}
}