F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der Backfill nimmt das Aktivierungsdatum nur noch ohne jede Registrierungsbestellung (offene Rechnung bleibt leer). F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel (Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig. F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit; eine Ausfuehrung loescht auch den paused_by_*-Grund. F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits committeten Test nachgezogen. F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false). Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal. Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
765 lines
26 KiB
PHP
765 lines
26 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Created by Reliese Model.
|
||
*/
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\PaymentProvider;
|
||
use App\Services\Abo\AboStatusExplainer;
|
||
use App\Services\Abo\AboStatusInfo;
|
||
use App\Services\Abo\TeamAboOverviewService;
|
||
use App\Services\AboHelper;
|
||
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::saving(function (UserAbo $abo): void {
|
||
$abo->applyStatusReasonDefaults();
|
||
});
|
||
|
||
static::updated(function (UserAbo $abo): void {
|
||
$abo->logStatusTransition();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ticket A3: Grund eines Zustands zentral am Model pflegen, damit jeder
|
||
* Schreibpfad (Cron, Retry, Recovery, Formular, spaeter B2) ihn hinterlaesst.
|
||
* Explizit gesetzte Werte im selben Save gewinnen.
|
||
*
|
||
* - Status 3 ohne Grund => `payment_failed` (jeder Weg nach 3 ist ein Zahlungsfehler).
|
||
* - Status 4 / 6 ohne Grund => `cancelled_by_*` / `deactivated_by_*` mit dem
|
||
* Kanal des laufenden Formulars ({@see withStatusLogView()}), sonst `system`.
|
||
* - Zurueck in den Lauf (2, 7, 8) => Grund geloescht.
|
||
* - `last_date` aendert sich nur durch eine Ausfuehrung (Erfolg oder Fehler):
|
||
* damit ist eine Pause vorbei, `paused_until` und ein `paused_by_*`-Grund
|
||
* werden geloescht.
|
||
*/
|
||
protected function applyStatusReasonDefaults(): void
|
||
{
|
||
if ($this->isDirty('status') && ! $this->isDirty('status_reason')) {
|
||
$this->status_reason = match ((int) $this->status) {
|
||
3 => self::REASON_PAYMENT_FAILED,
|
||
4 => self::REASON_PREFIX_CANCELLED.self::reasonActorFromView(static::$statusLogView),
|
||
6 => self::REASON_PREFIX_DEACTIVATED.self::reasonActorFromView(static::$statusLogView),
|
||
2, 7, 8 => null,
|
||
default => $this->status_reason,
|
||
};
|
||
}
|
||
|
||
if ($this->isDirty('last_date') && ! $this->isDirty('paused_until')) {
|
||
$this->paused_until = null;
|
||
|
||
if (! $this->isDirty('status_reason') && str_starts_with((string) $this->status_reason, self::REASON_PREFIX_PAUSED)) {
|
||
$this->status_reason = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Akteur fuer `status_reason` aus dem Formular-Kanal: Kundenportal => Kunde,
|
||
* CRM (eigenes Abo oder Kundenabo) => Berater, Admin => Admin, kein Kanal => System.
|
||
*/
|
||
public static function reasonActorFromView(?string $view): string
|
||
{
|
||
return match ($view) {
|
||
'portal' => self::ACTOR_CUSTOMER,
|
||
'me', 'ot' => self::ACTOR_CONSULTANT,
|
||
'admin' => self::ACTOR_ADMIN,
|
||
default => self::ACTOR_SYSTEM,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Ende der laufenden Pause. Fuer Bestandsdaten ohne `paused_until` gilt:
|
||
* Pause gezaehlt und seit der letzten Ausfuehrung nicht wieder geliefert
|
||
* => die Pause laeuft bis zum naechsten Termin.
|
||
*/
|
||
public function pausedUntil(): ?Carbon
|
||
{
|
||
if ($this->getRawOriginal('paused_until')) {
|
||
return Carbon::parse($this->getRawOriginal('paused_until'));
|
||
}
|
||
|
||
if ((int) $this->pause_count > 0 && $this->getRawOriginal('last_pause_date') && $this->getRawOriginal('next_date')) {
|
||
$lastDate = $this->getRawOriginal('last_date');
|
||
|
||
if (! $lastDate || Carbon::parse($lastDate)->lt(Carbon::parse($this->getRawOriginal('last_pause_date')))) {
|
||
return Carbon::parse($this->getRawOriginal('next_date'));
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
public function isPaused(): bool
|
||
{
|
||
if ((int) $this->status !== 2 || ! $this->active) {
|
||
return false;
|
||
}
|
||
|
||
$until = $this->pausedUntil();
|
||
|
||
return $until !== null && $until->gte(Carbon::today());
|
||
}
|
||
|
||
/**
|
||
* Laufende Abos, deren Pause noch nicht vorbei ist (Admin-Filter).
|
||
*/
|
||
public function scopePaused(Builder $query): Builder
|
||
{
|
||
return $query->where('status', 2)->where('active', true)->whereDate('paused_until', '>=', Carbon::today());
|
||
}
|
||
|
||
public function scopePaymentFailed(Builder $query): Builder
|
||
{
|
||
return $query->where('status', 3);
|
||
}
|
||
|
||
public function statusInfo(): AboStatusInfo
|
||
{
|
||
return app(AboStatusExplainer::class)->explain($this);
|
||
}
|
||
|
||
/**
|
||
* 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',
|
||
'paused_until' => 'date',
|
||
'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',
|
||
'paused_until',
|
||
'status_reason',
|
||
'user_deleted_at',
|
||
'card_update_pending_at',
|
||
];
|
||
|
||
/**
|
||
* Ticket A1: jeder Monatstag 1-31 ist als Liefertag waehlbar. In kuerzeren
|
||
* Monaten liefert ein Abo mit Tag 29-31 am letzten Tag des Monats
|
||
* ({@see AboHelper::dateForDayInMonth()}); der Folgemonat wird
|
||
* immer wieder aus `abo_interval` gerechnet, nie aus dem geklammerten Termin.
|
||
*/
|
||
public const MAX_DELIVERY_DAY = 31;
|
||
|
||
/**
|
||
* @return int[]
|
||
*/
|
||
public static function allowedDeliveryDays(): array
|
||
{
|
||
return range(1, self::MAX_DELIVERY_DAY);
|
||
}
|
||
|
||
public static function isAllowedDeliveryDay(mixed $day): bool
|
||
{
|
||
return is_numeric($day) && (int) $day == $day && in_array((int) $day, self::allowedDeliveryDays(), true);
|
||
}
|
||
|
||
/** `status_reason` (Ticket A3): Zahlungsfehler, Pause/Kuendigung/Deaktivierung je Akteur. */
|
||
public const REASON_PAYMENT_FAILED = 'payment_failed';
|
||
|
||
public const REASON_PREFIX_PAUSED = 'paused_by_';
|
||
|
||
public const REASON_PREFIX_CANCELLED = 'cancelled_by_';
|
||
|
||
public const REASON_PREFIX_DEACTIVATED = 'deactivated_by_';
|
||
|
||
public const ACTOR_CUSTOMER = 'customer';
|
||
|
||
public const ACTOR_CONSULTANT = 'consultant';
|
||
|
||
public const ACTOR_ADMIN = 'admin';
|
||
|
||
public const ACTOR_SYSTEM = 'system';
|
||
|
||
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');
|
||
}
|
||
}
|