A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad. A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung, Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller `active = true` nimmt es dauerhaft aus allen Folgemonaten. Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung. Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date` — bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen Monaten, jetzt bleibt die Historie korrekt. A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()` zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist — genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel. A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25 Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als letzte Regel entscheidet die user_id, damit eine gecachte Rangliste reproduzierbar bleibt. A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert; ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen Stornowerten, und `next_date` wurde doppelt formatiert. Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09 (Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17 (Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text) und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt 0,00 EUR). Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
620 lines
21 KiB
PHP
620 lines
21 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Created by Reliese Model.
|
||
*/
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\PaymentProvider;
|
||
use App\Services\Abo\TeamAboOverviewService;
|
||
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();
|
||
}
|
||
});
|
||
|
||
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<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');
|
||
}
|
||
}
|