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>
523 lines
17 KiB
PHP
523 lines
17 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Created by Reliese Model.
|
||
*/
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\PaymentProvider;
|
||
use App\Services\AboPaymentMethodService;
|
||
use App\Services\Backoffice\BackofficeDashboardService;
|
||
use App\Services\Util;
|
||
use App\User;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Database\Eloquent\Collection;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
|
||
/**
|
||
* Class UserAbo
|
||
*
|
||
* @property int $id
|
||
* @property int $user_id
|
||
* @property int $payone_userid
|
||
* @property string $clearingtype
|
||
* @property string|null $wallettype
|
||
* @property int $amount
|
||
* @property bool $active
|
||
* @property int $status
|
||
* @property int $abo_interval
|
||
* @property Carbon|null $abo_date
|
||
* @property Carbon|null $next_abo_date
|
||
* @property Carbon|null $cancel_date
|
||
* @property Carbon|null $created_at
|
||
* @property Carbon|null $updated_at
|
||
* @property string|null $deleted_at
|
||
* @property Carbon|null $user_deleted_at
|
||
* @property User $user
|
||
* @property Collection|UserAboOrder[] $user_abo_orders
|
||
* @property int|null $member_id
|
||
* @property int $shopping_user_id
|
||
* @property string|null $email
|
||
* @property string|null $is_for
|
||
* @property array|null $carddata
|
||
* @property \Illuminate\Support\Carbon|null $start_date
|
||
* @property \Illuminate\Support\Carbon|null $last_date
|
||
* @property \Illuminate\Support\Carbon|null $next_date
|
||
* @property-read User|null $member
|
||
* @property-read ShoppingUser $shopping_user
|
||
* @property-read Collection<int, UserAboItem> $user_abo_items
|
||
* @property-read Collection<int, UserAboOneTimeItem> $one_time_items
|
||
* @property-read int|null $user_abo_items_count
|
||
* @property-read int|null $user_abo_orders_count
|
||
*
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo newModelQuery()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo newQuery()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo onlyTrashed()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo query()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereAboInterval($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereActive($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereAmount($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereCancelDate($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereCarddata($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereClearingtype($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereCreatedAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereDeletedAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereEmail($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereId($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereIsFor($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereLastDate($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereMemberId($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereNextDate($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo wherePayoneUserid($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereShoppingUserId($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereStartDate($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereStatus($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereUpdatedAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereUserDeletedAt($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereUserId($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo whereWallettype($value)
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo withTrashed()
|
||
* @method static \Illuminate\Database\Eloquent\Builder|UserAbo withoutTrashed()
|
||
*
|
||
* @mixin \Eloquent
|
||
*/
|
||
class UserAbo extends Model
|
||
{
|
||
use SoftDeletes;
|
||
|
||
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',
|
||
'shopping_user_id' => 'int',
|
||
'payone_userid' => 'int',
|
||
'active' => 'bool',
|
||
'status' => 'int',
|
||
'abo_interval' => 'int',
|
||
'amount' => 'int',
|
||
'start_date' => 'datetime',
|
||
'last_date' => 'datetime',
|
||
'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',
|
||
|
||
];
|
||
|
||
protected $fillable = [
|
||
'user_id',
|
||
'member_id',
|
||
'shopping_user_id',
|
||
'is_for',
|
||
'email',
|
||
'payone_userid',
|
||
'payment_provider',
|
||
'stripe_customer_id',
|
||
'stripe_payment_method_id',
|
||
'clearingtype',
|
||
'wallettype',
|
||
'carddata',
|
||
'amount',
|
||
'active',
|
||
'status',
|
||
'abo_interval',
|
||
'start_date',
|
||
'last_date',
|
||
'next_date',
|
||
'onetime_reminder_sent_for',
|
||
'cancel_date',
|
||
'last_pause_date',
|
||
'pause_count',
|
||
'user_deleted_at',
|
||
'card_update_pending_at',
|
||
];
|
||
|
||
public static $aboDeliveryDays = [5, 10, 20, 25];
|
||
|
||
public static $statusTypes = [
|
||
0 => 'abo_new',
|
||
1 => 'abo_new',
|
||
2 => 'abo_okay',
|
||
3 => 'abo_hold',
|
||
4 => 'abo_cancel',
|
||
5 => 'abo_finish',
|
||
6 => 'abo_inactive',
|
||
7 => 'abo_grace',
|
||
8 => 'abo_extern_done',
|
||
];
|
||
|
||
public static $statusColors = [
|
||
0 => 'success',
|
||
1 => 'success',
|
||
2 => 'secondary',
|
||
3 => 'warning',
|
||
4 => 'danger',
|
||
5 => 'info',
|
||
6 => 'warning',
|
||
7 => 'danger',
|
||
8 => 'success',
|
||
];
|
||
|
||
/**
|
||
* Aktive Abos (laufend): active=true und kein terminaler/inaktiver Status
|
||
* (4 = storniert, 5 = beendet, 6 = inaktiv). Spiegelt die Definition aus
|
||
* {@see BackofficeDashboardService}.
|
||
*/
|
||
public function scopeActiveAbo(Builder $query): Builder
|
||
{
|
||
return $query
|
||
->where('active', true)
|
||
->whereNotIn('status', [4, 5, 6]);
|
||
}
|
||
|
||
/**
|
||
* Schränkt die Abfrage auf Abos ein, die im angegebenen Monat/Jahr hinzugekommen
|
||
* sind (Eingangsmonat = start_date). Bei month=0 (oder außerhalb 1–12) bleibt die
|
||
* Abfrage unverändert.
|
||
*/
|
||
public function scopeAddedInMonth(Builder $query, int $year, int $month): Builder
|
||
{
|
||
if ($month < 1 || $month > 12) {
|
||
return $query;
|
||
}
|
||
|
||
return $query
|
||
->whereYear('start_date', $year)
|
||
->whereMonth('start_date', $month);
|
||
}
|
||
|
||
public function user()
|
||
{
|
||
return $this->belongsTo(User::class, 'user_id');
|
||
}
|
||
|
||
public function member()
|
||
{
|
||
return $this->belongsTo(User::class, 'member_id');
|
||
}
|
||
|
||
public function shopping_user()
|
||
{
|
||
return $this->belongsTo('App\Models\ShoppingUser', 'shopping_user_id');
|
||
}
|
||
|
||
public function user_abo_orders()
|
||
{
|
||
return $this->hasMany(UserAboOrder::class);
|
||
}
|
||
|
||
public function initialUserAboOrder(): ?UserAboOrder
|
||
{
|
||
return $this->user_abo_orders()
|
||
->orderBy('id')
|
||
->first();
|
||
}
|
||
|
||
public function initialShoppingOrder(): ?ShoppingOrder
|
||
{
|
||
return $this->initialUserAboOrder()?->shopping_order;
|
||
}
|
||
|
||
public function initialShoppingPayment(): ?ShoppingPayment
|
||
{
|
||
return $this->initialShoppingOrder()?->shopping_payments()
|
||
->orderBy('id')
|
||
->first();
|
||
}
|
||
|
||
public function isPendingInitialPayment(): bool
|
||
{
|
||
return (int) $this->status === 1;
|
||
}
|
||
|
||
public function hasInitialPaymentStatus(): bool
|
||
{
|
||
$payment = $this->initialShoppingPayment();
|
||
$order = $this->initialShoppingOrder();
|
||
|
||
return filled($payment?->txaction)
|
||
|| filled($payment?->status)
|
||
|| filled($order?->txaction);
|
||
}
|
||
|
||
public function hasFailedInitialPayment(): bool
|
||
{
|
||
$payment = $this->initialShoppingPayment();
|
||
$order = $this->initialShoppingOrder();
|
||
|
||
return in_array($payment?->txaction, ['failed'], true)
|
||
|| in_array($payment?->status, ['error', 'cancel'], true)
|
||
|| in_array($order?->txaction, ['failed'], true);
|
||
}
|
||
|
||
public function canDeletePendingInitialPayment(): bool
|
||
{
|
||
return $this->isPendingInitialPayment() && $this->hasFailedInitialPayment();
|
||
}
|
||
|
||
public function isCreditCard(): bool
|
||
{
|
||
return $this->clearingtype === 'cc';
|
||
}
|
||
|
||
/**
|
||
* Selbstbedienungs-Detailseite dieses Abos: Berater-eigenes Abo (is_for=me) → CRM,
|
||
* Kunden-Abo → Kundenportal. Genutzt für Recovery-Links (Mail, Checkout-Zurück).
|
||
*/
|
||
public function selfServiceDetailUrl(): string
|
||
{
|
||
return $this->is_for === 'me'
|
||
? rtrim(Util::getMyMivitaUrl(), '/').'/user/abos/detail/me/'.$this->id
|
||
: rtrim(Util::getMyMivitaPortalUrl(), '/').'/portal/my-subscriptions';
|
||
}
|
||
|
||
/**
|
||
* Maskierte Kartendarstellung für die Anzeige, z. B. „411111XXXXXX1111 (12/30)".
|
||
* Nutzt ausschließlich Token-/Maskendaten aus carddata (nie PAN/CVC).
|
||
*/
|
||
public function maskedCard(): ?string
|
||
{
|
||
if (! $this->isCreditCard()) {
|
||
return null;
|
||
}
|
||
|
||
return self::formatMaskedCard(is_array($this->carddata) ? $this->carddata : null);
|
||
}
|
||
|
||
/**
|
||
* Baut die maskierte Kartendarstellung aus beliebigen carddata (Token-/Maskendaten).
|
||
*
|
||
* @param array<string, mixed>|null $carddata
|
||
*/
|
||
public static function formatMaskedCard(?array $carddata): ?string
|
||
{
|
||
$pan = $carddata['truncatedcardpan'] ?? null;
|
||
if (! $pan) {
|
||
// Stripe-Summary (type/brand/last4/exp aus payment_method_details)
|
||
if (! empty($carddata['last4'])) {
|
||
$label = ! empty($carddata['brand']) ? ucfirst($carddata['brand']).' ' : '';
|
||
$exp = ! empty($carddata['exp']) ? ' ('.$carddata['exp'].')' : '';
|
||
|
||
return $label.'•••• '.$carddata['last4'].$exp;
|
||
}
|
||
if (! empty($carddata['type'])) {
|
||
return ShoppingPayment::stripeLabelFromSummary($carddata);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
$expire = (string) ($carddata['cardexpiredate'] ?? '');
|
||
if (strlen($expire) === 4) {
|
||
return $pan.' ('.substr($expire, 2, 2).'/'.substr($expire, 0, 2).')';
|
||
}
|
||
|
||
return $pan;
|
||
}
|
||
|
||
public function card_changes(): HasMany
|
||
{
|
||
return $this->hasMany(UserAboCardChange::class)->latest();
|
||
}
|
||
|
||
/**
|
||
* Ob der Kreditkarten-Token dieses Abos aktualisiert werden darf. Guard-agnostische
|
||
* Invariante: nur CC-Abos, nicht gelöscht, kein terminaler/inaktiver Status
|
||
* (4 = storniert, 5 = beendet, 6 = inaktiv). Die Eigentümer-/Guard-Prüfung erfolgt
|
||
* zusätzlich im jeweiligen Controller (CRM bzw. Portal).
|
||
*/
|
||
public function canUpdatePaymentMethod(): bool
|
||
{
|
||
return $this->isCreditCard()
|
||
&& ! $this->trashed()
|
||
&& ! in_array((int) $this->status, [4, 5, 6], true);
|
||
}
|
||
|
||
/**
|
||
* Zustands-Invariante ohne die "nur CC"-Einschränkung — für den
|
||
* Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5), der jede Zahlart
|
||
* wechseln kann. Welcher Flow gilt, entscheidet
|
||
* {@see AboPaymentMethodService::canStartChange()}.
|
||
*/
|
||
public function canChangePaymentMethodAnyType(): bool
|
||
{
|
||
return ! $this->trashed()
|
||
&& ! in_array((int) $this->status, [4, 5, 6], true);
|
||
}
|
||
|
||
public function user_abo_items()
|
||
{
|
||
return $this->hasMany(UserAboItem::class);
|
||
}
|
||
|
||
public function status_histories()
|
||
{
|
||
return $this->hasMany(UserAboStatusHistory::class);
|
||
}
|
||
|
||
public function one_time_items()
|
||
{
|
||
return $this->hasMany(UserAboOneTimeItem::class);
|
||
}
|
||
|
||
public function user_abo_item_histories()
|
||
{
|
||
return $this->hasMany(UserAboItemHistory::class);
|
||
}
|
||
|
||
public function getInitialItems()
|
||
{
|
||
return $this->user_abo_item_histories()
|
||
->where('is_initial', true)
|
||
->where('comp', 0)
|
||
->orderBy('created_at')
|
||
->get();
|
||
}
|
||
|
||
public function getInitialCompItems()
|
||
{
|
||
return $this->user_abo_item_histories()
|
||
->where('is_initial', true)
|
||
->where('comp', '>', 0)
|
||
->orderBy('comp')
|
||
->get();
|
||
}
|
||
|
||
public function getChangeHistory()
|
||
{
|
||
return $this->user_abo_item_histories()
|
||
->where('is_initial', false)
|
||
->orderByDesc('created_at')
|
||
->get();
|
||
}
|
||
|
||
public function getCountOrders()
|
||
{
|
||
// sind bezahlte Bestellungen
|
||
return $this->user_abo_orders->where('status', '>=', 2)->count();
|
||
}
|
||
|
||
public function getCountPaidOrders()
|
||
{
|
||
// Ausgefuehrte Abo-Lieferungen: bezahlte sowie extern erledigte (Status 8,
|
||
// abo_extern_done) Bestellungen. Extern erledigt = Lieferung ausgefuehrt und
|
||
// Punkte vergeben, daher zaehlt sie wie eine bezahlte Lieferung (z. B. fuer die
|
||
// Mindestlaufzeit), obwohl paid=false bleibt (Inkasso laeuft ausserhalb).
|
||
return $this->user_abo_orders
|
||
->where('status', '>=', 2)
|
||
->filter(fn (UserAboOrder $order) => $order->paid || (int) $order->status === 8)
|
||
->count();
|
||
}
|
||
|
||
public function setStartDateAttribute($value)
|
||
{
|
||
$this->attributes['start_date'] = isset($value) ? (new Carbon($value))->format('Y-m-d') : null;
|
||
}
|
||
|
||
public function getStartDateAttribute()
|
||
{
|
||
return $this->attributes['start_date'] ? Carbon::parse($this->attributes['start_date'])->format(\Util::formatDateDB()) : '';
|
||
}
|
||
|
||
public function setLastDateAttribute($value)
|
||
{
|
||
$this->attributes['last_date'] = isset($value) ? (new Carbon($value))->format('Y-m-d') : null;
|
||
}
|
||
|
||
public function getLastDateAttribute()
|
||
{
|
||
return $this->attributes['last_date'] ? Carbon::parse($this->attributes['last_date'])->format(\Util::formatDateDB()) : '';
|
||
}
|
||
|
||
public function setNextDateAttribute($value)
|
||
{
|
||
$this->attributes['next_date'] = isset($value) ? (new Carbon($value))->format('Y-m-d') : null;
|
||
}
|
||
|
||
public function getNextDateAttribute()
|
||
{
|
||
return $this->attributes['next_date'] ? Carbon::parse($this->attributes['next_date'])->format(\Util::formatDateDB()) : '';
|
||
}
|
||
|
||
public function setCancelDateAttribute($value)
|
||
{
|
||
$this->attributes['cancel_date'] = isset($value) ? (new Carbon($value))->format('Y-m-d') : null;
|
||
}
|
||
|
||
public function getCancelDateAttribute()
|
||
{
|
||
return $this->attributes['cancel_date'] ? Carbon::parse($this->attributes['cancel_date'])->format(\Util::formatDateDB()) : '';
|
||
}
|
||
|
||
public function getFormattedAmount(): string
|
||
{
|
||
return isset($this->attributes['amount']) ? Util::formatNumber($this->attributes['amount'] / 100) : '';
|
||
}
|
||
|
||
public function getTotalPoints(): float
|
||
{
|
||
return $this->user_abo_items
|
||
->where('comp', 0)
|
||
->sum(fn ($item) => ($item->product?->points ?? 0) * $item->qty);
|
||
}
|
||
|
||
public function getFormattedTotalPoints(): string
|
||
{
|
||
return Util::formatNumber($this->getTotalPoints());
|
||
}
|
||
|
||
public function getIsForFormated()
|
||
{
|
||
return $this->attributes['is_for'] === 'me' ? '<span class="badge badge-outline-warning-dark">'.__('tables.adviser').'</span>' : '<span class="badge badge-outline-info">'.__('tables.customer').'</span>';
|
||
}
|
||
|
||
public function getStatusFormated()
|
||
{
|
||
return '<span class="badge badge-pill badge-'.$this->getStatusColor().'">'.$this->getStatusType().'</span>';
|
||
}
|
||
|
||
public function getStatusType()
|
||
{
|
||
return isset(self::$statusTypes[$this->status]) ? __('abo.'.self::$statusTypes[$this->status]) : '';
|
||
}
|
||
|
||
public function getStatusColor()
|
||
{
|
||
return isset(self::$statusColors[$this->status]) ? self::$statusColors[$this->status] : 'default';
|
||
}
|
||
|
||
public function getPaymentType()
|
||
{
|
||
if ($this->payment_provider === PaymentProvider::Stripe->value) {
|
||
return ShoppingPayment::stripeLabelFromSummary(is_array($this->carddata) ? $this->carddata : []);
|
||
}
|
||
|
||
return $this->clearingtype === 'wlt' ? __('payment.paypal') : __('payment.credit_card');
|
||
}
|
||
}
|