mivita/app/Models/UserAbo.php
Kevin ce4750fd1a Stripe-Integration (S4, S5, S7.6): Abos, Zahlungsart ändern, Zahlarten-Kommunikation
Phase S4 – Abos über Stripe (E2E komplett im Testmodus):
- Abo-/Recovery-Checkout: customer_creation=always + setup_future_usage=off_session
- StripePaymentService::chargeAbo (off-session PaymentIntent, Idempotency-Key),
  StripeGateway, Provider-Dispatch im Abbuchungs-Cron (PAYONE-Pfad unverändert)
- Webhook: Zahlungsmittel-Capture vor Paid-Pfad, 503 payment_not_yet_visible bei
  Cron-Transaktions-Race; Command stripe:process-pending-events (Cron-Ende + 10-min
  Scheduler) verarbeitet liegengebliebene Events nach
- createNewAbo/advanceAboAfterRecoveryPayment Stripe-aware inkl. Race-Absicherung;
  Recovery-Orders nutzen advanceAboAfterRecoveryPayment (latenter PAYONE-Duplikat-Bug)
- Migrationen: Stripe-Abo-Spalten auf shopping_payments; clearingtype varchar(3)→(20)
  inkl. Reparatur der zu 'str' gekürzten Live-Zeilen

Phase S5 – Zahlungsart ändern (Scope: jede Zahlart, nicht nur Kreditkarte):
- Stripe Checkout mode=setup ohne Abbuchung; PAYONE-Abos ziehen beim Wechsel auf
  Stripe um (Auslaufmodell §7.2)
- AboPaymentMethodService: beginStripeChange/finalize/applyStripePaymentMethod
  (idempotent, detacht alte PM, card_changes-Log); Webhook setup_intent.succeeded
  als Sicherheitsnetz; Einstiege Portal/CRM/Berater-Kunden-Link flow-abhängig
- Views/Mails flow-abhängig („Zahlungsweise" statt „Kreditkarte")

Phase S7.6 – Zahlarten-Kommunikation:
- Einheitliche Zahlarten-Logos (SVG) + wiederverwendbares Partial payment_logos;
  Checkout/Footer zeigen Kern-Zahlarten + „···"-Icon, /zahlungsarten komplett neu
  mit allen aktiven Stripe-Zahlarten (gegen Account-Konfiguration verifiziert)
  und Vorkasse (PAYONE); Sofort/SEPA entfernt

Tests: StripeAboTest (15), StripePaymentMethodChangeTest (10),
PaymentMethodsCommunicationTest (28), PaymentProviderManagerTest angepasst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:45:17 +00:00

497 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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';
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',
'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',
'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 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');
}
}