$user_abo_items * @property-read Collection $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(); } }); static::updated(function (UserAbo $abo): void { $abo->logStatusTransition(); }); } /** * Kanal des laufenden Schreibvorgangs (Formular-View) für das Statusprotokoll. * Systempfade — Cron, Zahlungs-Recovery, Webhooks — lassen ihn leer und * erscheinen in der Historie als „System". */ protected static ?string $statusLogView = null; /** * Führt den Callback aus und schreibt alle dabei entstehenden Protokolleinträge * mit diesem Kanal. Verschachtelung ist erlaubt: der vorherige Kanal wird * wiederhergestellt, auch wenn der Callback eine Exception wirft. */ public static function withStatusLogView(?string $view, \Closure $callback): mixed { $previous = static::$statusLogView; static::$statusLogView = $view; try { return $callback(); } finally { static::$statusLogView = $previous; } } /** * A16: Jeder Wechsel des Aktiv-Zustands wird protokolliert, unabhängig vom * Schreibpfad (Formular, Admin, Zahlungs-Recovery, Retry, externe Erledigung). * * Hintergrund: Die historische Bestandsrückrechnung in * {@see TeamAboOverviewService::aboStockQuery()} verwirft ein * Abo, solange eine protokollierte Deaktivierung nicht durch eine spätere * Reaktivierung aufgehoben ist. Ein Schreibpfad, der still auf `active = true` * zurücksetzt, nimmt das Abo damit dauerhaft aus allen vergangenen Monaten — * deshalb liegt der Eintrag hier am Model und nicht in den einzelnen Diensten. * * Grenzen: Model-Events feuern weder bei Query-Builder-Massenupdates noch bei * `saveQuietly()`. Das Löschen braucht keinen Eintrag, die Rückrechnung wertet * dafür `deleted_at` aus. */ protected function logStatusTransition(): void { if (! $this->wasChanged('active')) { return; } if ($this->active) { $action = UserAboStatusHistory::ACTION_REACTIVATED; } else { // Status 4 = gekündigt; jede andere Abschaltung ist eine Deaktivierung. $action = (int) $this->status === 4 ? UserAboStatusHistory::ACTION_CANCELLED : UserAboStatusHistory::ACTION_DEACTIVATED; } UserAboStatusHistory::create([ 'user_abo_id' => $this->id, 'action' => $action, 'view' => static::$statusLogView, 'actor_user_id' => \Auth::id(), ]); } 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]); } /** * Abos mit noch offener Ausführung im Fenster von heute bis $windowEnd — * gemeinsame Grundlage aller Prognosen (Payline-Kachel, persönliche * Terminvorschau, Team-Übersicht). * * A13: Ein Abo mit `next_date` = heute galt bisher ab Mitternacht als * erledigt (`next_date > heute 23:59:59`). Der Ausführungsjob läuft aber erst * um 04:00 — bis dahin fehlten die Punkte in der Prognose UND im Ist. Deshalb * zählt der heutige Liefertag mit, solange für das Abo heute noch keine * Ausführung angelegt wurde. Sobald sie existiert, stecken die Punkte im Ist; * die Bedingung verhindert also gerade die Doppelzählung. */ public function scopeOpenExecutionUntil(Builder $query, Carbon $today, Carbon $windowEnd): Builder { $nextDate = $query->qualifyColumn('next_date'); return $query ->where($nextDate, '<=', $windowEnd) ->where(function (Builder $open) use ($nextDate, $today) { $open ->where($nextDate, '>', $today->copy()->endOfDay()) ->orWhere(fn (Builder $dueToday) => $dueToday ->whereDate($nextDate, $today->toDateString()) ->whereDoesntHave('user_abo_orders', fn ($order) => $order ->whereDate('user_abo_orders.created_at', $today->toDateString()) ) ); }); } /** * 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|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' ? ''.__('tables.adviser').'' : ''.__('tables.customer').''; } public function getStatusFormated() { return ''.$this->getStatusType().''; } 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'); } }