mivita/app/Services/AboHelper.php
Kevin e7b40bc513 Abo-Recovery: fehlgeschlagene Folgezahlung im Checkout nachholen
- Pay-now-Flow: bei fehlgeschlagenem Abo (Status 3) begleicht der Inhaber die
  offene Rechnung im Standard-Checkout mit frei waehlbarer, abo-faehiger Zahlart
  (nur eigenes Abo: Berater me-View + Kundenportal), payNow-Controller + Routen
- AboRecoveryCheckoutService baut die ShoppingInstance aus den Abo-Artikeln;
  neue Spalte shopping_orders.recovery_user_abo_id verknuepft Bestellung<->Abo
- Bei Erfolg: AboHelper::advanceAboAfterRecoveryPayment schreibt das Abo fort
  (Status->2, next_date) und uebernimmt die gewaehlte Zahlart als neues Token
  fuer kuenftige Abbuchungen
- Neue Pay-now-Fehlermail (AboPaymentFailedMail) ersetzt die generische
  Fehlermail im Abo-Cron; Link fuehrt auf die Abo-Detailseite (CRM bzw. Portal)
- Retry-/"extern erledigt"-Buttons nur noch fuer Admins; tote user/portal-
  Retry-Routen entfernt
- Checkout: Recovery-Hinweis statt "Abschluss des Abonnements", Lieferadresse
  vorbelegt, korrekter Zurueck-Link (Abo statt Shop)
- UserAbo::selfServiceDetailUrl zentralisiert die Abo-URL (Mail, Back-Links)
- Test-Command abo:test-recovery; Tests fuer payNow, Advance-Logik, Fehlermail

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:38:05 +00:00

646 lines
23 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
namespace App\Services;
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAboItemHistory;
use App\Models\UserAboOrder;
use App\Services\Incentive\IncentiveTracker;
use App\User;
use Carbon\Carbon;
class AboHelper
{
/**
* Mindestabstand (Kalendertage) vom Bestell-/Referenzdatum bis zur ersten Abo-Ausführung.
*/
public const MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10;
/**
* Standard-Zeitfenster (Kalendertage) vor der Ausführung, in dem einmalige
* Produkte aus dem normalen Sortiment hinzugefügt werden dürfen.
*/
public const DEFAULT_ONETIME_WINDOW_DAYS = 4;
public static $txaction_filter_text = [
'paid' => 'paymend_paid',
'appointed' => 'paymend_open',
'failed' => 'paymend_failed',
'extern' => 'extern_open', // offen
'extern_paid' => 'extern_paid',
'invoice_open' => 'invoice_open',
'invoice_paid' => 'invoice_paid',
'invoice_non' => 'invoice_no_payment',
'NULL' => 'no_payment',
];
public static function userHasAbo(User $user)
{
$user = $user ? $user : \Auth::user();
return UserAbo::where('user_id', $user->id)->where('is_for', 'me')->where('status', '>=', 1)->first() === null ? false : true;
}
public static function memberHasAbo(ShoppingUser $shopping_user)
{
if (! $shopping_user) {
return false;
}
return UserAbo::where('email', $shopping_user->billing_email)->where('is_for', 'ot')->where('status', '>=', 1)->first() === null ? false : true;
}
public static function hasAboByEmail($email)
{
return UserAbo::where('email', $email)->where('status', '>=', 1)->first() === null ? false : true;
}
public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false)
{
$user_abo = $shopping_order->getUserAbo();
if ($user_abo) {
// Neuaktivierung nach erfolgreicher Zahlung (z. B. Payone paid): immer wieder auf abo_okay (2),
// auch wenn das Abo vorher abo_hold (3) war (z. B. Cron-Zahlung fehlgeschlagen, spaeter bezahlt).
if ($paid && (int) $status === 2) {
$user_abo->update(['status' => 2]);
} elseif ($user_abo->status < 2) {
$user_abo->update(['status' => $status]);
}
}
if (! $user_abo) {
return;
}
UserAboOrder::where('user_abo_id', $user_abo->id)->where('shopping_order_id', $shopping_order->id)->update(['status' => $status, 'paid' => $paid]);
}
public static function setAboActive(ShoppingOrder $shopping_order, $status, $paid = false)
{
self::setAboStatus($shopping_order, $status, $paid);
// delete UserAbo is not active status = 1
// is_for = me
UserAbo::where('user_id', $shopping_order->auth_user_id)->where('is_for', 'me')->where('status', 1)->delete();
// is_for = ot
UserAbo::where('member_id', $shopping_order->member_id)->where('email', $shopping_order->shopping_user->billing_email)->where('is_for', 'ot')->where('status', 1)->delete();
}
public static function getAboMinDuration()
{
return \App\Models\Setting::getContentBySlug('abo-min-duration');
}
public static function canCancelAbo(UserAbo $user_abo, $view = 'user')
{
$minDuration = self::getAboMinDuration();
if ($view === 'admin') {
return true;
}
$paidOrdersCount = $user_abo->getCountPaidOrders();
return $paidOrdersCount >= (int) $minDuration;
}
public static function isAddOnlyMode(UserAbo $user_abo, $view = 'user'): bool
{
if ($view === 'admin') {
return false;
}
return ! self::canCancelAbo($user_abo, $view);
}
/**
* Prüft, ob das Bearbeiten des Abos (Liefertag/Intervall) aktuell durch die
* Sperrfrist vor der Ausführung blockiert ist. Admins umgehen die Sperre.
* Spiegelt die serverseitige Prüfung in {@see \App\Repositories\AboRepository::validate()}
* für die UI (Feld/Button deaktivieren).
*/
public static function isAboChangeLocked(UserAbo $userAbo, string $view = 'user'): bool
{
if ($view === 'admin' || ! $userAbo->next_date) {
return false;
}
$daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false);
return $daysUntil >= 0 && $daysUntil < \App\Repositories\AboRepository::getLockDaysChange();
}
public static function canEditAbo($user_abo, $view = 'user')
{
if ($view === 'portal') {
return true;
}
$user = \Auth::user();
if ($view !== 'admin' && (! $user || ($user_abo->user_id != $user->id && $user_abo->member_id != $user->id))) {
return false;
}
return true;
}
public static function aboHasBaseProduct($yard_products)
{
foreach ($yard_products as $product) {
if (is_array($product->options->show_on)) {
if (in_array('12', $product->options->show_on)) {
return true;
}
}
}
return false;
}
public static function getAboShowOn(Product $product)
{
$show_on = $product->show_on;
if (in_array('12', $show_on)) {
return 'base';
}
if (in_array('13', $show_on)) {
return 'upgrade';
}
return false;
}
public static function getAboTypeBadge($abo_type)
{
if ($abo_type === 'base') {
return '<span class="badge badge-pill badge-warning"><i class="fas fa-star"></i> '.__('abo.'.$abo_type).'</span></a>';
}
if ($abo_type === 'upgrade') {
return '<span class="badge badge-pill badge-info"><i class="far fa-star"></i> '.__('abo.'.$abo_type).'</span></a>';
}
return '';
}
public static function setNextDate($date, $abo_interval)
{
$nextDate = Carbon::parse($date)->firstOfMonth();
$nextDate->addDays($abo_interval - 1);
return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1);
}
/**
* Konfiguriertes Zeitfenster (Kalendertage) für einmalige Produkte vor der Ausführung.
*/
public static function getOneTimeWindowDays(): int
{
$days = (int) \App\Models\Setting::getContentBySlug('abo-onetime-window-days');
return $days > 0 ? $days : self::DEFAULT_ONETIME_WINDOW_DAYS;
}
/**
* Prüft, ob für dieses Abo aktuell einmalig Produkte aus dem normalen
* Bestellsortiment hinzugefügt werden dürfen (Zeitfenster vor der Ausführung).
*
* Nur die zeitliche Bedingung wird geprüft. Aufrufer sollten zusätzlich den
* Abo-Zustand (active/status) berücksichtigen, falls relevant.
*/
public static function isOneTimeWindowOpen(UserAbo $userAbo): bool
{
if (! $userAbo->next_date) {
return false;
}
$today = Carbon::today();
$nextDate = Carbon::parse($userAbo->next_date)->startOfDay();
if ($nextDate->lt($today)) {
return false;
}
$daysUntilExecution = $today->diffInDays($nextDate);
return $daysUntilExecution <= self::getOneTimeWindowDays();
}
/**
* Sichtbarkeit des Einmalprodukte-Features:
* - VIP-User (Admin) sehen/nutzen das Feature dauerhaft, unabhängig vom Zeitfenster.
* - Alle anderen (normale Berater im Sales Center sowie Endkunden im Portal, die nie
* auf dem `user`-Guard angemeldet sind) nur innerhalb des konfigurierten Zeitfensters
* vor der Ausführung ({@see isOneTimeWindowOpen}, Setting `abo-onetime-window-days`).
*/
public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool
{
$user = \Auth::guard('user')->user();
if ($user instanceof User && $user->isVIP()) {
return true;
}
return self::isOneTimeWindowOpen($userAbo);
}
public static function getFirstAboDate($date, $abo_interval)
{
$reference = Carbon::parse($date)->startOfDay();
$candidate = self::computeFirstAboCandidateWithoutMinDays($reference, (int) $abo_interval);
while ($reference->diffInDays($candidate->copy()->startOfDay(), true) < self::MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION) {
$candidate = self::advanceAboCandidateOneMonth($candidate, (int) $abo_interval);
}
return $candidate;
}
/**
* Kalendertage von $from bis $to (nur Datum, ohne Uhrzeit).
* Verhindert Abweichungen, wenn {@see now()} eine Tageszeit hat und Carbon {@see diffInDays} in 24h-Schritten zählt.
*/
public static function calendarDaysUntil(Carbon|string $from, Carbon $to): int
{
$start = Carbon::parse($from)->startOfDay();
$end = $to->copy()->startOfDay();
return (int) $start->diffInDays($end, true);
}
/**
* Erste mögliche Ausführung (nächster Monat, gewählter Liefertag) ohne Mindestabstand-Regel.
*/
private static function computeFirstAboCandidateWithoutMinDays(Carbon $reference, int $aboDayOfMonth): Carbon
{
$nextDate = $reference->copy()->firstOfMonth()->addMonth(1);
$nextDate->addDays($aboDayOfMonth - 1);
if (! $nextDate->gt($reference)) {
$nextDate->addMonth(1);
}
return $nextDate->copy()->startOfDay();
}
/**
* Gleicher Liefertag im Folgemonat (Monatsende beachten).
*/
private static function advanceAboCandidateOneMonth(Carbon $candidate, int $aboDayOfMonth): Carbon
{
$next = $candidate->copy()->addMonthNoOverflow();
$dim = $next->daysInMonth;
$day = min($aboDayOfMonth, $dim);
return $next->day($day)->startOfDay();
}
public static function createNewAbo(ShoppingPayment $shopping_payment)
{
$order = $shopping_payment->shopping_order;
if (! $order || ! $order->is_abo || (int) $order->abo_interval <= 0) {
return;
}
// Bereits verknüpft (z. B. Checkout-Erfolgsseite vor Callback) oder wiederholter Aufruf
if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) {
return;
}
$aboInterval = (int) ($shopping_payment->abo_interval ?? $order->abo_interval);
if ($aboInterval <= 0) {
return;
}
$payment_transaction = $shopping_payment->payment_transactions->last();
$payoneUserId = $payment_transaction ? (int) $payment_transaction->userid : 0;
// next_date immer im nächsten Monat starten
// is auth_user_id = Berater bestellung
// is member_id = Kunden bestellung
// is for = me = mich oder ot = kunde
$user_abo = UserAbo::create([
'user_id' => $order->auth_user_id,
'member_id' => $order->member_id,
'shopping_user_id' => $order->shopping_user_id,
'email' => $order->shopping_user->billing_email,
'is_for' => $order->shopping_user->is_for,
'payone_userid' => $payoneUserId,
'clearingtype' => $shopping_payment->clearingtype,
'wallettype' => $shopping_payment->wallettype,
'carddata' => $shopping_payment->carddata,
'amount' => $shopping_payment->amount,
'status' => 1,
'abo_interval' => $aboInterval,
'start_date' => now(),
'last_date' => now(),
'next_date' => self::getFirstAboDate(now(), $aboInterval),
]);
if ($user_abo) {
self::createAboItems($user_abo, $shopping_payment);
UserAboOrder::create([
'user_abo_id' => $user_abo->id,
'shopping_order_id' => $shopping_payment->shopping_order_id,
'status' => 1,
]);
// Payone-Status-URL kann vor dem Checkout-Redirect laufen: dann existierte
// noch kein UserAboOrder → Payment::paymentStatusPaidAction → trackAboActivated ohne Wirkung.
// Nach Anlage hier erneut versuchen, wenn die Bestellung bereits als bezahlt gilt.
$shopping_payment->shopping_order->refresh();
if ($shopping_payment->shopping_order->paid) {
IncentiveTracker::trackAboActivated($shopping_payment->shopping_order);
}
}
}
/**
* Schreibt ein bestehendes Abo nach einer erfolgreichen Recovery-Zahlung fort
* (der Inhaber hat die offene Folgerechnung über den Checkout mit freier Zahlart beglichen).
* Es wird KEIN neues Abo angelegt – nur das vorhandene Abo reaktiviert und der nächste Termin gesetzt.
*/
public static function advanceAboAfterRecoveryPayment(ShoppingPayment $shopping_payment): void
{
$order = $shopping_payment->shopping_order;
if (! $order || ! $order->recovery_user_abo_id) {
return;
}
$userAbo = UserAbo::find($order->recovery_user_abo_id);
if (! $userAbo) {
return;
}
// Bereits verknüpft (z. B. Payone-Status-URL vor Checkout-Redirect) oder wiederholter Aufruf
if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) {
return;
}
$aboInterval = (int) ($userAbo->abo_interval ?: $order->abo_interval);
// Die im Recovery-Checkout gewählte Zahlart wird als neues Abo-Zahlungsmittel übernommen,
// damit auch künftige Folgeabbuchungen darüber laufen (das alte Token war i. d. R. defekt).
$paymentTransaction = $shopping_payment->payment_transactions->last();
$payoneUserId = $paymentTransaction && (int) $paymentTransaction->userid > 0
? (int) $paymentTransaction->userid
: (int) $userAbo->payone_userid;
\DB::transaction(function () use ($userAbo, $order, $aboInterval, $shopping_payment, $payoneUserId): void {
$userAbo->update([
'status' => 2,
'active' => true,
'clearingtype' => $shopping_payment->clearingtype,
'wallettype' => $shopping_payment->wallettype,
'carddata' => $shopping_payment->carddata,
'payone_userid' => $payoneUserId,
'next_date' => $aboInterval > 0 ? self::setNextDate(now(), $aboInterval) : $userAbo->next_date,
'last_date' => now(),
]);
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $order->id,
'status' => 1,
'paid' => true,
]);
if (! empty($shopping_payment->carddata)) {
$userAbo->card_changes()->create([
'masked_card' => UserAbo::formatMaskedCard($shopping_payment->carddata),
'source' => 'recovery-checkout',
]);
}
});
try {
$order->refresh();
if ($order->paid) {
IncentiveTracker::trackAboActivated($order);
}
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('AboHelper::advanceAboAfterRecoveryPayment: Incentive-Tracking fehlgeschlagen', [
'abo_id' => $userAbo->id,
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
AboOneTimeService::purgeAfterExecution($userAbo);
}
public static function createAboItems($user_abo, ShoppingPayment $shopping_payment)
{
foreach ($shopping_payment->shopping_order->shopping_order_items as $item) {
UserAboItem::create([
'user_abo_id' => $user_abo->id,
'product_id' => $item->product_id,
'comp' => $item->comp ?? 0,
'qty' => $item->qty,
'status' => 1,
]);
}
$user_abo->load('user_abo_items');
AboItemHistoryService::logInitialCreation($user_abo, 'system');
}
/**
* Stellt Abo-Artikel aus der letzten Bestellung mit Positionen wieder her, wenn user_abo_items leer sind
* (z. B. manuell angelegtes Abo ohne Checkout-AboItem-Anlage).
*/
public static function ensureUserAboItemsFromLatestOrder(UserAbo $userAbo): bool
{
if ($userAbo->user_abo_items()->exists()) {
return true;
}
$userAboOrders = $userAbo->user_abo_orders()
->orderByDesc('id')
->with(['shopping_order.shopping_order_items'])
->get();
$order = null;
foreach ($userAboOrders as $link) {
$shoppingOrder = $link->shopping_order;
if ($shoppingOrder && $shoppingOrder->shopping_order_items->isNotEmpty()) {
$order = $shoppingOrder;
break;
}
}
if (! $order) {
return false;
}
foreach ($order->shopping_order_items as $item) {
UserAboItem::create([
'user_abo_id' => $userAbo->id,
'product_id' => $item->product_id,
'comp' => $item->comp ?? 0,
'qty' => $item->qty,
'status' => 1,
]);
}
$userAbo->unsetRelation('user_abo_items');
if (! UserAboItemHistory::query()
->where('user_abo_id', $userAbo->id)
->where('is_initial', true)
->exists()) {
$userAbo->load('user_abo_items');
AboItemHistoryService::logInitialCreation($userAbo, 'system');
}
return true;
}
public static function getTransStatusFilterText()
{
$ret = [];
foreach (self::$txaction_filter_text as $key => $val) {
$ret[$key] = trans('payment.'.$val);
}
return $ret;
}
/**
* Prüft effizient, ob ein User im Team eines anderen Users ist (Downline).
* Traversiert die Sponsor-Hierarchie rekursiv (m_sponsor) statt die komplette
* TreeCalcBot-Struktur aufzubauen.
*
* @param int $teamOwnerId ID des Team-Users (Berechtigter)
* @param int $userToCheckId ID des zu prüfenden Users (z.B. Abo-Besitzer)
* @param int $maxDepth Max. Tiefe (Schutz vor zirkulären Referenzen)
* @return bool True wenn userToCheckId im Team von teamOwnerId ist
*/
public static function isUserInTeam(int $teamOwnerId, int $userToCheckId, int $maxDepth = 100): bool
{
if ($teamOwnerId === $userToCheckId) {
return true;
}
$currentId = $userToCheckId;
$depth = 0;
while ($depth < $maxDepth) {
$currentUser = User::where('id', $currentId)->select('m_sponsor')->first();
if (! $currentUser || ! $currentUser->m_sponsor) {
return false;
}
if ($currentUser->m_sponsor === $teamOwnerId) {
return true;
}
$currentId = $currentUser->m_sponsor;
$depth++;
}
return false;
}
/**
* Liefert alle User-IDs im Team (Downline) eines Users.
* Traversiert die Sponsor-Hierarchie rekursiv nach unten statt TreeCalcBot.
*
* @param int $teamOwnerId ID des Team-Users
* @param int $maxDepth Max. Tiefe (Schutz vor Endlosschleifen)
* @return int[]
*/
public static function getTeamUserIds(int $teamOwnerId, int $maxDepth = 50): array
{
$teamUserIds = [];
$toProcess = [$teamOwnerId];
$depth = 0;
while (! empty($toProcess) && $depth < $maxDepth) {
$children = User::whereIn('m_sponsor', $toProcess)
->whereNull('deleted_at')
->pluck('id')
->toArray();
$teamUserIds = array_merge($teamUserIds, $children);
$toProcess = $children;
$depth++;
}
return array_values(array_unique($teamUserIds));
}
/**
* Berechnet die Anzahl aktiver Abos pro Monat für ein gegebenes Jahr.
* Ein Abo gilt als aktiv in Monat M wenn:
* - start_date <= letzter Tag von M
* - cancel_date ist NULL oder >= erster Tag von M
*
* @param \Illuminate\Database\Eloquent\Builder $query Basis-Query (gefiltert nach User/Team etc.)
* @param int $year Jahr für die Berechnung
* @return int[] Array mit 12 Einträgen (Index 0 = Januar, 11 = Dezember)
*/
/**
* Liefert die Abo-Zählung pro Monat für ein Jahr.
*
* Vergangene Monate → aus DB-Snapshot (eingefroren, unabhängig von Strukturänderungen).
* Aktueller Monat → live berechnet.
* Zukünftige Monate → null (kein Balken im Chart).
*
* @param \Illuminate\Database\Eloquent\Builder $liveQuery Basis-Query für den aktuellen Monat
* @param string $scope 'ot' | 'team_abos' | 'team_cust_abos'
* @param int $userId Eingeloggter Berater
* @return array<int, int|null> 12 Einträge (Index 0 = Jan), null = Zukunft
*/
public static function getMonthlyAboCounts(
\Illuminate\Database\Eloquent\Builder $liveQuery,
int $year,
string $scope,
int $userId
): array {
$data = [];
$now = Carbon::now();
$currentYear = (int) $now->year;
$currentMonth = (int) $now->month;
$lastCountableMonth = ($year === $currentYear) ? $currentMonth : 12;
// Alle vorhandenen Snapshots für diesen User/Scope/Jahr auf einmal laden
$snapshots = \App\Models\AboChartSnapshot::where('user_id', $userId)
->where('scope', $scope)
->where('year', $year)
->get()
->keyBy('month');
for ($month = 1; $month <= 12; $month++) {
if ($month > $lastCountableMonth) {
$data[] = null;
continue;
}
$isPastMonth = $year < $currentYear || ($year === $currentYear && $month < $currentMonth);
if ($isPastMonth && $snapshots->has($month)) {
// Eingefroren – aus DB
$data[] = $snapshots->get($month)->count;
} else {
// Aktueller Monat oder noch kein Snapshot → live
$startOfMonth = Carbon::create($year, $month, 1)->startOfMonth();
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
$terminalStatuses = [4, 5];
$data[] = (clone $liveQuery)
->whereDate('start_date', '<=', $endOfMonth)
->where(function ($q) use ($startOfMonth, $terminalStatuses) {
$q->whereDate('cancel_date', '>=', $startOfMonth)
->orWhere(function ($q2) use ($terminalStatuses) {
$q2->whereNull('cancel_date')
->whereNotIn('status', $terminalStatuses);
});
})
->count();
}
}
return $data;
}
}