Abo (A3): Status und Grund eines Abos sichtbar machen

Neue Spalten user_abos.status_reason / paused_until und
user_abo_orders.error_message. Der Grund wird zentral am Model gepflegt
(saving-Event): Zahlungsfehler, Kuendigung/Deaktivierung mit Akteur aus dem
Formular-Kanal, Loeschen beim Zurueckkehren in den Lauf; die Pause setzt
paused_until und paused_by_*. Cron- und Retry-Zahlung schreiben den Fehlertext
des Anbieters an die Abo-Bestellung.

AboStatusExplainer uebersetzt das in einen Satz ("Pausiert bis ... vom Kunden
am ...", "Zahlung am ... fehlgeschlagen - Grund: ...", "Gekuendigt am ... vom
Berater"), Partial _status_explain in Abo-Detail (CRM, Portal, Admin),
Abo-Uebersicht, Kundenabo-Liste, Team-Kundenabos und Admin-Datatable; dort
neuer Filter "Zustand" (pausiert / Zahlung fehlgeschlagen). Backfill-Kommando
abo:backfill-status-reasons fuer Bestandsdaten, Fallback ueber das
Statusprotokoll und die Pausenfelder.

Sprachdateien de/en/es/fr, Tests in AboStatusExplainTest, Plan-Status.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-16 10:24:18 +00:00
parent e8057f9f7b
commit d6efe56cd4
22 changed files with 977 additions and 15 deletions

View file

@ -0,0 +1,96 @@
<?php
namespace App\Console\Commands;
use App\Models\UserAbo;
use App\Models\UserAboStatusHistory;
use Carbon\Carbon;
use Illuminate\Console\Command;
/**
* Einmaliger Nachtrag fuer Bestandsdaten (Ticket A3): fuellt `status_reason`
* und `paused_until` aus Status, Pausenfeldern und dem Statusprotokoll
* (`user_abo_status_histories`). Laesst bereits gefuellte Werte unberuehrt und
* schreibt ueber Query-Builder, damit weder Model-Events noch `updated_at`
* angefasst werden.
*/
class AboBackfillStatusReasons extends Command
{
protected $signature = 'abo:backfill-status-reasons {--dry-run : Nur zaehlen, nichts schreiben}';
protected $description = 'status_reason / paused_until fuer bestehende Abos aus Status und Statusprotokoll nachtragen';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$counts = ['payment_failed' => 0, 'cancelled' => 0, 'deactivated' => 0, 'paused' => 0];
UserAbo::withTrashed()
->whereNull('status_reason')
->whereIn('status', [3, 4, 6])
->orderBy('id')
->chunkById(500, function ($abos) use (&$counts, $dryRun): void {
foreach ($abos as $abo) {
[$reason, $bucket] = match ((int) $abo->status) {
3 => [UserAbo::REASON_PAYMENT_FAILED, 'payment_failed'],
4 => [UserAbo::REASON_PREFIX_CANCELLED.$this->actorFromHistory($abo, UserAboStatusHistory::ACTION_CANCELLED), 'cancelled'],
default => [UserAbo::REASON_PREFIX_DEACTIVATED.$this->actorFromHistory($abo, UserAboStatusHistory::ACTION_DEACTIVATED), 'deactivated'],
};
$counts[$bucket]++;
if (! $dryRun) {
UserAbo::withTrashed()->whereKey($abo->id)->update(['status_reason' => $reason]);
}
}
});
UserAbo::query()
->whereNull('paused_until')
->where('status', 2)
->where('active', true)
->where('pause_count', '>', 0)
->whereNotNull('last_pause_date')
->orderBy('id')
->chunkById(500, function ($abos) use (&$counts, $dryRun): void {
foreach ($abos as $abo) {
$until = $abo->pausedUntil();
if ($until === null || $until->lt(Carbon::today())) {
continue;
}
$counts['paused']++;
if (! $dryRun) {
UserAbo::whereKey($abo->id)->update([
'paused_until' => $until->toDateString(),
'status_reason' => $abo->status_reason ?: UserAbo::REASON_PREFIX_PAUSED.$this->actorFromHistory($abo, UserAboStatusHistory::ACTION_PAUSED),
]);
}
}
});
$this->table(['Zustand', 'Abos'], [
['Zahlung fehlgeschlagen', $counts['payment_failed']],
['Gekuendigt', $counts['cancelled']],
['Deaktiviert', $counts['deactivated']],
['Pausiert (laufend)', $counts['paused']],
]);
$this->info($dryRun ? 'DRY-RUN, nichts geschrieben.' : 'Nachtrag abgeschlossen.');
return self::SUCCESS;
}
private function actorFromHistory(UserAbo $abo, string $action): string
{
$entry = UserAboStatusHistory::query()
->where('user_abo_id', $abo->id)
->where('action', $action)
->orderByDesc('id')
->first();
return UserAbo::reasonActorFromView($entry?->view);
}
}

View file

@ -6,6 +6,7 @@ use App\Cron\UserMakeOrder;
use App\Mail\AboPaymentFailedMail;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\Abo\AboStatusExplainer;
use App\Services\AboHelper;
use App\Services\AboOneTimeService;
use App\Services\Incentive\IncentiveTracker;
@ -428,7 +429,13 @@ class UserMakeAboOrder extends Command
$this->info("Aktualisiere Abo bei Fehler: {$userAbo->id} (Status: {$status})");
try {
DB::transaction(function () use ($userAbo, $shoppingOrder, $status) {
// Ticket A3: Fehlertext des Zahlungsanbieters (Karte abgelaufen, Deckung,
// PayPal) an der Bestellung festhalten — sonst geht der Grund verloren.
$errorMessage = is_numeric($status)
? AboStatusExplainer::paymentErrorMessage(is_array($errorResponse) ? $errorResponse : null)
: mb_substr((string) $status, 0, 255);
DB::transaction(function () use ($userAbo, $shoppingOrder, $status, $errorMessage) {
// Nur last_date aktualisieren, next_date bleibt unverändert
// Damit wird das Abo beim nächsten Cron-Lauf erneut versucht
$updateData = [
@ -448,6 +455,7 @@ class UserMakeAboOrder extends Command
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $shoppingOrder->id,
'status' => $orderStatus,
'error_message' => $errorMessage,
'paid' => false,
]);

View file

@ -27,7 +27,7 @@ class AboController extends Controller
public function index()
{
if (Request::get('reset') === 'filter') {
foreach (['filter_member_id', 'filter_status', 'filter_active', 'filter_is_for', 'filter_payment', 'filter_start_month', 'filter_start_year', 'filter_next_day'] as $attr) {
foreach (['filter_member_id', 'filter_status', 'filter_state', 'filter_active', 'filter_is_for', 'filter_payment', 'filter_start_month', 'filter_start_year', 'filter_next_day'] as $attr) {
set_user_attr($attr, null);
}
@ -197,6 +197,15 @@ class AboController extends Controller
$query->where('active', '=', (int) Request::get('filter_active'));
}
// Ticket A3: Zustand (pausiert / Zahlung fehlgeschlagen) — quer zum Status,
// weil eine Pause keinen eigenen Status hat.
set_user_attr('filter_state', Request::get('filter_state'));
if (Request::get('filter_state') === 'paused') {
$query->paused();
} elseif (Request::get('filter_state') === 'payment_failed') {
$query->paymentFailed();
}
set_user_attr('filter_is_for', Request::get('filter_is_for'));
if (Request::get('filter_is_for') != '') {
$query->where('is_for', '=', Request::get('filter_is_for'));
@ -236,7 +245,7 @@ class AboController extends Controller
return HTMLHelper::getAboStrLang($user_abo->abo_interval);
})
->addColumn('status', function (UserAbo $user_abo) {
return $user_abo->getStatusFormated();
return view('admin.abo._status_explain', ['abo' => $user_abo, 'compact' => true])->render();
})
->addColumn('active', function (UserAbo $user_abo) {
return get_active_badge($user_abo->active);

View file

@ -7,6 +7,8 @@
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\AboPaymentMethodService;
use App\Services\Backoffice\BackofficeDashboardService;
@ -108,11 +110,109 @@ class UserAbo extends Model
}
});
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` wird 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;
}
}
/**
* 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
@ -191,6 +291,7 @@ class UserAbo extends Model
'cancel_date' => 'datetime',
'last_pause_date' => 'date',
'pause_count' => 'int',
'paused_until' => 'date',
'user_deleted_at' => 'datetime',
'carddata' => 'array',
'card_update_pending_at' => 'datetime',
@ -221,12 +322,31 @@ class UserAbo extends Model
'cancel_date',
'last_pause_date',
'pause_count',
'paused_until',
'status_reason',
'user_deleted_at',
'card_update_pending_at',
];
public static $aboDeliveryDays = [5, 10, 20, 25];
/** `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',

View file

@ -49,6 +49,7 @@ class UserAboOrder extends Model
'user_id',
'shopping_order_id',
'status',
'error_message',
'paid',
];

View file

@ -237,6 +237,9 @@ class AboRepository extends BaseRepository
$this->model->next_date = $newNextDate;
$this->model->last_pause_date = now();
$this->model->pause_count = (int) $this->model->pause_count + 1;
// Ticket A3: Pause sichtbar machen — bis wann und durch wen.
$this->model->paused_until = $newNextDate->copy()->startOfDay();
$this->model->status_reason = UserAbo::REASON_PREFIX_PAUSED.UserAbo::reasonActorFromView($data['view'] ?? null);
$this->model->save();
$this->logStatusHistory(UserAboStatusHistory::ACTION_PAUSED, $data, $previousNextDate, $newNextDate);

View file

@ -0,0 +1,215 @@
<?php
namespace App\Services\Abo;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAboStatusHistory;
use App\Repositories\AboRepository;
use Carbon\Carbon;
/**
* Uebersetzt Status, Grund und Zeitpunkte eines Abos in eine Aussage, die Admin,
* Berater und Kunde ohne Rueckfrage verstehen (Ticket A3): "Pausiert bis
* 24.10.2026 – vom Kunden am 12.09.2026", "Zahlung am 10.09.2026 fehlgeschlagen
* (Karte abgelaufen) – das Abo laeuft nicht, bis die Zahlung nachgeholt ist",
* "Gekuendigt am 01.08.2026 vom Berater".
*
* Quellen: `user_abos.status` / `status_reason` / `paused_until` (gepflegt
* durch {@see UserAbo::applyStatusReasonDefaults()} und die Pause im
* {@see AboRepository}), `user_abo_orders.error_message`
* (Fehlertext des Zahlungsanbieters) und als Rueckfall fuer Bestandsdaten das
* Statusprotokoll {@see UserAboStatusHistory}.
*/
class AboStatusExplainer
{
public function explain(UserAbo $abo): AboStatusInfo
{
$status = (int) $abo->status;
if ($abo->isPendingInitialPayment() || $status === 0) {
return $this->pendingPayment($abo);
}
return match ($status) {
3 => $this->paymentFailed($abo),
4 => $this->cancelled($abo),
5 => new AboStatusInfo(AboStatusInfo::STATE_FINISHED, __('abo.state_finished'), 'dark', __('abo.state_text_finished')),
6 => $this->deactivated($abo),
7 => new AboStatusInfo(AboStatusInfo::STATE_GRACE, __('abo.state_grace'), 'info', __('abo.state_text_grace')),
8 => new AboStatusInfo(AboStatusInfo::STATE_EXTERN_DONE, __('abo.state_extern_done'), 'info', __('abo.state_text_extern_done')),
2 => $abo->active ? $this->running($abo) : $this->deactivated($abo),
default => new AboStatusInfo(AboStatusInfo::STATE_UNKNOWN, $abo->getStatusType() ?: __('abo.state_unknown'), $abo->getStatusColor(), ''),
};
}
/**
* Kurzer Fehlertext aus einer Payone-/Stripe-Antwort fuer
* `user_abo_orders.error_message` — null, wenn die Antwort nichts Lesbares
* enthaelt.
*
* @param array<string, mixed>|null $response
*/
public static function paymentErrorMessage(?array $response): ?string
{
if (! $response) {
return null;
}
$message = $response['customermessage'] ?? $response['errormessage'] ?? $response['message'] ?? null;
$code = $response['errorcode'] ?? $response['decline_code'] ?? $response['code'] ?? null;
if (! is_string($message) || trim($message) === '') {
return null;
}
$text = trim($message);
if (is_scalar($code) && (string) $code !== '') {
$text = '['.$code.'] '.$text;
}
return mb_substr($text, 0, 255);
}
private function running(UserAbo $abo): AboStatusInfo
{
$pausedUntil = $abo->pausedUntil();
if ($pausedUntil !== null && $pausedUntil->gte(Carbon::today())) {
$actor = $this->actorFromReason($abo->status_reason, 'paused_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_PAUSED);
$date = $abo->last_pause_date ? Carbon::parse($abo->getRawOriginal('last_pause_date')) : null;
return new AboStatusInfo(
AboStatusInfo::STATE_PAUSED,
__('abo.state_paused'),
'warning',
__('abo.state_text_paused', [
'until' => $pausedUntil->format(\Util::formatDateDB()),
'actor' => $this->actorLabel($actor),
'date' => $date ? $date->format(\Util::formatDateDB()) : '-',
]),
$date,
$actor,
);
}
$last = $this->rawDate($abo, 'last_date');
$next = $this->rawDate($abo, 'next_date');
$text = $last
? __('abo.state_text_active', ['last' => $last->format(\Util::formatDateDB()), 'next' => $next?->format(\Util::formatDateDB()) ?? '-'])
: __('abo.state_text_active_first', ['next' => $next?->format(\Util::formatDateDB()) ?? '-']);
return new AboStatusInfo(AboStatusInfo::STATE_ACTIVE, __('abo.state_active'), 'success', $text, $next);
}
private function paymentFailed(UserAbo $abo): AboStatusInfo
{
$date = $this->rawDate($abo, 'last_date');
$error = $this->latestErrorMessage($abo);
$text = __('abo.state_text_payment_failed', ['date' => $date ? $date->format(\Util::formatDateDB()) : '-']);
if ($error !== null) {
$text .= ' '.__('abo.state_text_payment_error', ['error' => $error]);
}
return new AboStatusInfo(AboStatusInfo::STATE_PAYMENT_FAILED, __('abo.state_payment_failed'), 'danger', $text, $date, null, $error);
}
private function cancelled(UserAbo $abo): AboStatusInfo
{
$actor = $this->actorFromReason($abo->status_reason, 'cancelled_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_CANCELLED);
$date = $this->rawDate($abo, 'cancel_date') ?? $this->historyDate($abo, UserAboStatusHistory::ACTION_CANCELLED);
return new AboStatusInfo(
AboStatusInfo::STATE_CANCELLED,
__('abo.state_cancelled'),
'dark',
__('abo.state_text_cancelled', [
'date' => $date ? $date->format(\Util::formatDateDB()) : '-',
'actor' => $this->actorLabel($actor),
]),
$date,
$actor,
);
}
private function deactivated(UserAbo $abo): AboStatusInfo
{
$actor = $this->actorFromReason($abo->status_reason, 'deactivated_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_DEACTIVATED);
$date = $this->historyDate($abo, UserAboStatusHistory::ACTION_DEACTIVATED);
$text = $date
? __('abo.state_text_deactivated', ['date' => $date->format(\Util::formatDateDB()), 'actor' => $this->actorLabel($actor)])
: __('abo.state_text_deactivated_no_date', ['actor' => $this->actorLabel($actor)]);
return new AboStatusInfo(AboStatusInfo::STATE_DEACTIVATED, __('abo.state_deactivated'), 'secondary', $text, $date, $actor);
}
private function pendingPayment(UserAbo $abo): AboStatusInfo
{
if ($abo->hasFailedInitialPayment()) {
$text = __('abo.pending_payment_failed_copy');
} elseif ($abo->hasInitialPaymentStatus()) {
$text = __('abo.pending_payment_processing_copy');
} else {
$text = __('abo.pending_payment_waiting_copy');
}
return new AboStatusInfo(AboStatusInfo::STATE_PENDING_PAYMENT, __('abo.state_pending_payment'), 'warning', $text);
}
private function latestErrorMessage(UserAbo $abo): ?string
{
$order = $abo->relationLoaded('user_abo_orders')
? $abo->user_abo_orders->sortByDesc('id')->first(fn (UserAboOrder $order) => (int) $order->status === 3)
: $abo->user_abo_orders()->where('status', 3)->orderByDesc('id')->first();
$message = $order?->error_message;
return is_string($message) && trim($message) !== '' ? $message : null;
}
private function actorFromReason(?string $reason, string $prefix): ?string
{
if (! $reason || ! str_starts_with($reason, $prefix)) {
return null;
}
return substr($reason, strlen($prefix)) ?: null;
}
private function actorFromHistory(UserAbo $abo, string $action): ?string
{
$entry = $this->latestHistory($abo, $action);
return $entry ? UserAbo::reasonActorFromView($entry->view) : null;
}
private function historyDate(UserAbo $abo, string $action): ?Carbon
{
return $this->latestHistory($abo, $action)?->created_at?->copy();
}
private function latestHistory(UserAbo $abo, string $action): ?UserAboStatusHistory
{
return $abo->status_histories()->where('action', $action)->orderByDesc('id')->first();
}
private function actorLabel(?string $actor): string
{
return __('abo.actor_by_'.($actor ?: UserAbo::ACTOR_SYSTEM));
}
private function rawDate(UserAbo $abo, string $column): ?Carbon
{
$raw = $abo->getRawOriginal($column);
return $raw ? Carbon::parse($raw) : null;
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Services\Abo;
use Carbon\Carbon;
/**
* Erklaerter Zustand eines Abos fuer die Anzeige (Ticket A3): kurzer Zustand
* als Badge, ein Satz zur Erklaerung, dazu Datum und Akteur, soweit bekannt.
*/
final class AboStatusInfo
{
public const STATE_ACTIVE = 'active';
public const STATE_PAUSED = 'paused';
public const STATE_PAYMENT_FAILED = 'payment_failed';
public const STATE_CANCELLED = 'cancelled';
public const STATE_DEACTIVATED = 'deactivated';
public const STATE_PENDING_PAYMENT = 'pending_payment';
public const STATE_FINISHED = 'finished';
public const STATE_GRACE = 'grace';
public const STATE_EXTERN_DONE = 'extern_done';
public const STATE_UNKNOWN = 'unknown';
public function __construct(
public readonly string $state,
public readonly string $label,
public readonly string $color,
public readonly string $text,
public readonly ?Carbon $date = null,
public readonly ?string $actor = null,
public readonly ?string $errorMessage = null,
) {}
public function badge(): string
{
return '<span class="badge badge-pill badge-'.e($this->color).'">'.e($this->label).'</span>';
}
}

View file

@ -5,6 +5,7 @@ namespace App\Services;
use App\Cron\UserMakeOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\Abo\AboStatusExplainer;
use App\Services\Incentive\IncentiveTracker;
use App\Services\Payment\PaymentProviderManager;
use Illuminate\Support\Facades\DB;
@ -86,7 +87,7 @@ class AboRetryPaymentService
}
$this->logPaymentError($userAbo, $shoppingOrder, $response);
$this->markAboError($userAbo, $shoppingOrder);
$this->markAboError($userAbo, $shoppingOrder, AboStatusExplainer::paymentErrorMessage($response));
$paymentAttemptRecorded = true;
$this->sendPaymentErrorMail($userOrder, $shoppingOrder, $response);
@ -107,7 +108,7 @@ class AboRetryPaymentService
]);
if ($shoppingOrder && ! $paymentAttemptRecorded) {
$this->markAboError($userAbo, $shoppingOrder);
$this->markAboError($userAbo, $shoppingOrder, mb_substr('Exception: '.$e->getMessage(), 0, 255));
}
return [
@ -170,9 +171,9 @@ class AboRetryPaymentService
AboOneTimeService::purgeAfterExecution($userAbo);
}
private function markAboError(UserAbo $userAbo, mixed $shoppingOrder): void
private function markAboError(UserAbo $userAbo, mixed $shoppingOrder, ?string $errorMessage = null): void
{
DB::transaction(function () use ($userAbo, $shoppingOrder): void {
DB::transaction(function () use ($userAbo, $shoppingOrder, $errorMessage): void {
$userAbo->update([
'status' => 3,
'last_date' => now(),
@ -182,6 +183,7 @@ class AboRetryPaymentService
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $shoppingOrder->id,
'status' => 3,
'error_message' => $errorMessage,
'paid' => false,
]);
});

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Ticket A3 (Entwicklungsplan September 2026): Status und Grund eines Abos
* sichtbar machen. `status_reason` haelt fest, warum ein Abo in seinem Zustand
* ist und wer ihn ausgeloest hat (z. B. `payment_failed`, `paused_by_customer`,
* `cancelled_by_consultant`), `paused_until` das Ende einer Pause, und
* `user_abo_orders.error_message` den Fehlertext des Zahlungsanbieters.
* Bestandsdaten fuellt `abo:backfill-status-reasons`.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('user_abos', function (Blueprint $table) {
$table->string('status_reason', 40)->nullable()->after('status');
$table->date('paused_until')->nullable()->after('last_pause_date');
});
Schema::table('user_abo_orders', function (Blueprint $table) {
$table->string('error_message', 255)->nullable()->after('status');
});
}
public function down(): void
{
Schema::table('user_abos', function (Blueprint $table) {
$table->dropColumn(['status_reason', 'paused_until']);
});
Schema::table('user_abo_orders', function (Blueprint $table) {
$table->dropColumn('error_message');
});
}
};

View file

@ -1,6 +1,6 @@
# Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026
**Stand:** 16.09.2026 · **Status:** C1/C2 live seit 14.09., A4 umgesetzt (Commit, Deploy offen); als Nächstes A3, dann A1+A2
**Stand:** 16.09.2026 · **Status:** C1/C2 live seit 14.09., A4 und A3 umgesetzt (committet, Deploy offen); als Nächstes A1+A2
**Quellen:** `2026 09 Protokoll September-komprimiert.pdf` (Protokoll), `2026-09-14.md` (Ticket-Entwurf Abos #1–#3)
**Verwandte Pläne:** `dev/2026-07-22/entwicklungsplan-2026-09-07.md` (K11–K14 behandeln dieselben Themen aus dem Kundenbrief; die dortigen Rückfragen F4–F7 sind weiterhin offen), `dev/2026-09-10-business-plan/` (Ledger-Plan, löst A07/K11 langfristig)
@ -12,7 +12,7 @@
|---|---|---|---|---|---|
| **A1** | Abo-Ausführungstag frei wählbar (1–31, Monatsende-Regel) | Ticket #1, Protokoll | 3–4 PT | mittel | – |
| **A2** | Änderungssperre erst ab 0:00 Uhr am Ausführungstag | Ticket #1 | 1 PT | niedrig | zusammen mit A1 |
| **A3** | Abo-Status und Grund sichtbar (Zahlung / Kunde / Admin) | Ticket #2, Protokoll | 2–3 PT | niedrig | – |
| **A3** | Abo-Status und Grund sichtbar (Zahlung / Kunde / Admin) – **umgesetzt 16.09., Deploy offen (Migration + Backfill)** | Ticket #2, Protokoll | 2–3 PT | niedrig | – |
| **A4** | Pausieren/Kündigen beschriften, Kündigungs-Hinweis (6 Ausführungen) – **umgesetzt 16.09., Deploy offen** | Ticket #3, Protokoll | 1 PT | niedrig | – |
| **A5** | Kundenabo → Partnerabo automatisch umwandeln | Protokoll | 4–6 PT | hoch | Rückfragen R5 |
| **B1** | Jahresgebühr automatisch abbuchen (Opt-in-Häkchen) | Protokoll | 6–8 PT | hoch | Rückfragen R6 |
@ -95,7 +95,7 @@
**Risiko:** niedrig. Zeitliche Lücke 0:00–04:00 Uhr ist durch die neue Bedingung geschlossen.
### A3 – Status und Grund eines Abos sichtbar machen
### A3 – Status und Grund eines Abos sichtbar machen — umgesetzt 16.09., Deploy offen
**Ziel (Ticket #2, Protokoll):** Für Admin, Berater und Kunde sofort erkennbar: aktiv und ordnungsgemäß ausgeführt / pausiert bis wann und durch wen / Zahlung fehlgeschlagen (und dass sie fehlgeschlagen ist) / gekündigt durch wen.
@ -120,6 +120,17 @@
**Risiko:** niedrig. Reine Zusatzinformation, keine Verhaltensänderung.
**Umsetzung 16.09.2026 (Commit auf `payment/phase-r-payone-refactoring`):**
- **Migration** `2026_09_16_120000_add_status_reason_to_user_abos_table`: `user_abos.status_reason` (string 40, nullable), `user_abos.paused_until` (date, nullable), `user_abo_orders.error_message` (string 255, nullable). Keine bestehende Zeile wird verändert; auf dem Dev-MySQL hin- und zurückmigriert.
- **Grund zentral am Model** (`UserAbo::applyStatusReasonDefaults()` im `saving`-Event, statt in jedem der sechs Schreibpfade): Status 3 ohne Grund → `payment_failed`; Status 4 / 6 → `cancelled_by_*` / `deactivated_by_*` mit dem Formular-Kanal aus `withStatusLogView()` (portal → customer, me/ot → consultant, admin → admin, sonst system); zurück in den Lauf (2/7/8) → Grund gelöscht; Änderung von `last_date` (= eine Ausführung lief, Erfolg oder Fehler) → `paused_until` gelöscht. Die Pause im `AboRepository` setzt `paused_until` (= neues `next_date`) und `paused_by_*`.
- **Fehlertext der Zahlung:** `UserMakeAboOrder::updateAboOnError()` und `AboRetryPaymentService::markAboError()` schreiben `user_abo_orders.error_message` (`[Code] customermessage/errormessage`, max. 255 Zeichen, über `AboStatusExplainer::paymentErrorMessage()`); bei Exceptions der Exception-Text.
- **Lesen:** `App\Services\Abo\AboStatusExplainer::explain(UserAbo): AboStatusInfo` (Zustand, Label, Farbe, Satz, Datum, Akteur, Fehlertext). Zustände: aktiv (letzte/nächste Lieferung), pausiert (bis wann, durch wen, seit wann), Zahlung fehlgeschlagen (Datum + Grund laut Anbieter), gekündigt (Datum, durch wen), deaktiviert, wartet auf erste Zahlung, beendet, Kulanz, extern erledigt. Für Bestandsdaten ohne Grund kommt der Akteur aus `user_abo_status_histories`, die Pause aus `pause_count`/`last_pause_date` vs. `last_date` (Fallback in `UserAbo::pausedUntil()`).
- **Anzeige** über `admin/abo/_status_explain.blade.php` in allen Listen/Details: Abo-Detail (`admin/abo/_detail`, damit CRM me/ot, Kundenportal und Admin), CRM-Abo-Übersicht, Kundenabo-Liste, Team-Kundenabos, Admin-Datatable (Spalte Status). Admin-Filter „Zustand“: pausiert / Zahlung fehlgeschlagen (Scopes `UserAbo::paused()` / `paymentFailed()`).
- **Backfill** `php artisan abo:backfill-status-reasons [--dry-run]`: Status 3/4/6 ohne Grund und laufende Pausen ohne `paused_until` (Query-Builder, keine Model-Events, `updated_at` unverändert).
- Sprachdateien de/en/es/fr (`state_*`, `state_text_*`, `actor_by_*`, `filter_state`). Tests: `AboStatusExplainTest` (11 Tests), alle 40 Abo-Testdateien grün bis auf `AboPendingInitialPaymentTest` „zeigt gesendete Abo-Zahlungslinks“, der schon auf dem committeten Stand fehlschlägt (rendert `user/abo/index` mit `Form::open` ohne Session) — nicht Teil von A3.
- **Deploy:** Dateien hochladen, dann `php artisan migrate`, `php artisan abo:backfill-status-reasons --dry-run` (Zahlen prüfen), `php artisan abo:backfill-status-reasons`, `php artisan view:clear`. Keine Settings.
### A4 – Pausieren/Kündigen beschriften, Kündigungs-Hinweis — umgesetzt 16.09., Deploy offen
**Ziel (Ticket #3, Protokoll):** Im Partnershop/Portal sofort sichtbar, dass und wo man pausieren/kündigen kann. Beim Kündigen deutlicher Hinweis: neues Abo braucht wieder 6 Ausführungen; Vorschlag, stattdessen zu pausieren.

View file

@ -348,4 +348,32 @@ return [
'cancel_anyway_button' => 'Trotzdem kündigen',
'cancel_confirm_checkbox' => 'Ich habe verstanden, dass das Abo endet und ein neues Abo wieder :min Pflichtlieferungen hat.',
'cancel_success' => 'Dein Abo wurde gekündigt.',
// Zustand mit Erklaerung (Ticket A3)
'state_active' => 'Aktiv',
'state_paused' => 'Pausiert',
'state_payment_failed' => 'Zahlung fehlgeschlagen',
'state_cancelled' => 'Gekündigt',
'state_deactivated' => 'Deaktiviert',
'state_pending_payment' => 'Wartet auf erste Zahlung',
'state_finished' => 'Beendet',
'state_grace' => 'Kulanz',
'state_extern_done' => 'Extern erledigt',
'state_unknown' => 'Unbekannt',
'state_text_active' => 'Läuft planmäßig – letzte Lieferung :last, nächste Lieferung :next.',
'state_text_active_first' => 'Läuft planmäßig – erste Lieferung am :next.',
'state_text_paused' => 'Pausiert bis :until – :actor am :date. Die nächste Lieferung kommt am :until.',
'state_text_payment_failed' => 'Zahlung am :date fehlgeschlagen – das Abo läuft nicht weiter, bis die Zahlung nachgeholt ist.',
'state_text_payment_error' => 'Grund laut Zahlungsanbieter: :error',
'state_text_cancelled' => 'Gekündigt am :date :actor. Es gibt keine weiteren Lieferungen.',
'state_text_deactivated' => 'Deaktiviert :actor am :date. Es gibt keine Lieferungen, bis es wieder aktiviert wird.',
'state_text_deactivated_no_date' => 'Deaktiviert :actor. Es gibt keine Lieferungen, bis es wieder aktiviert wird.',
'state_text_finished' => 'Das Abo ist beendet.',
'state_text_grace' => 'Kulanz: Lieferung ohne erfolgreiche Zahlung freigegeben.',
'state_text_extern_done' => 'Die Lieferung wurde außerhalb des Systems erledigt.',
'actor_by_customer' => 'vom Kunden',
'actor_by_consultant' => 'vom Berater',
'actor_by_admin' => 'vom Admin',
'actor_by_system' => 'vom System',
'filter_state' => 'Zustand',
];

View file

@ -347,4 +347,31 @@ return [
'cancel_anyway_button' => 'Cancel anyway',
'cancel_confirm_checkbox' => 'I understand that the subscription ends and a new subscription has :min mandatory deliveries again.',
'cancel_success' => 'Your subscription has been cancelled.',
'state_active' => 'Active',
'state_paused' => 'Paused',
'state_payment_failed' => 'Payment failed',
'state_cancelled' => 'Cancelled',
'state_deactivated' => 'Deactivated',
'state_pending_payment' => 'Waiting for first payment',
'state_finished' => 'Finished',
'state_grace' => 'Goodwill',
'state_extern_done' => 'Completed externally',
'state_unknown' => 'Unknown',
'state_text_active' => 'Running as planned – last delivery :last, next delivery :next.',
'state_text_active_first' => 'Running as planned – first delivery on :next.',
'state_text_paused' => 'Paused until :until – :actor on :date. The next delivery is on :until.',
'state_text_payment_failed' => 'Payment failed on :date – the subscription does not continue until the payment has been made up.',
'state_text_payment_error' => 'Reason according to the payment provider: :error',
'state_text_cancelled' => 'Cancelled on :date :actor. There are no further deliveries.',
'state_text_deactivated' => 'Deactivated :actor on :date. There are no deliveries until it is reactivated.',
'state_text_deactivated_no_date' => 'Deactivated :actor. There are no deliveries until it is reactivated.',
'state_text_finished' => 'The subscription has ended.',
'state_text_grace' => 'Goodwill: delivery released without a successful payment.',
'state_text_extern_done' => 'The delivery was completed outside the system.',
'actor_by_customer' => 'by the customer',
'actor_by_consultant' => 'by the consultant',
'actor_by_admin' => 'by the admin',
'actor_by_system' => 'by the system',
'filter_state' => 'State',
];

View file

@ -347,4 +347,31 @@ return [
'cancel_anyway_button' => 'Cancelar de todos modos',
'cancel_confirm_checkbox' => 'Entiendo que la suscripción termina y que una nueva suscripción vuelve a tener :min entregas obligatorias.',
'cancel_success' => 'Tu suscripción ha sido cancelada.',
'state_active' => 'Activa',
'state_paused' => 'Pausada',
'state_payment_failed' => 'Pago fallido',
'state_cancelled' => 'Cancelada',
'state_deactivated' => 'Desactivada',
'state_pending_payment' => 'Esperando el primer pago',
'state_finished' => 'Finalizada',
'state_grace' => 'Cortesía',
'state_extern_done' => 'Completada externamente',
'state_unknown' => 'Desconocido',
'state_text_active' => 'Funciona según lo previsto: última entrega :last, próxima entrega :next.',
'state_text_active_first' => 'Funciona según lo previsto: primera entrega el :next.',
'state_text_paused' => 'Pausada hasta el :until – :actor el :date. La próxima entrega será el :until.',
'state_text_payment_failed' => 'El pago del :date falló: la suscripción no continúa hasta que se realice el pago pendiente.',
'state_text_payment_error' => 'Motivo según el proveedor de pagos: :error',
'state_text_cancelled' => 'Cancelada el :date :actor. No habrá más entregas.',
'state_text_deactivated' => 'Desactivada :actor el :date. No habrá entregas hasta que se reactive.',
'state_text_deactivated_no_date' => 'Desactivada :actor. No habrá entregas hasta que se reactive.',
'state_text_finished' => 'La suscripción ha finalizado.',
'state_text_grace' => 'Cortesía: entrega liberada sin un pago correcto.',
'state_text_extern_done' => 'La entrega se completó fuera del sistema.',
'actor_by_customer' => 'por el cliente',
'actor_by_consultant' => 'por el asesor',
'actor_by_admin' => 'por el administrador',
'actor_by_system' => 'por el sistema',
'filter_state' => 'Estado actual',
];

View file

@ -347,4 +347,31 @@ return [
'cancel_anyway_button' => 'Résilier quand même',
'cancel_confirm_checkbox' => 'J’ai compris que l’abonnement prend fin et qu’un nouvel abonnement comporte de nouveau :min livraisons obligatoires.',
'cancel_success' => 'Ton abonnement a été résilié.',
'state_active' => 'Actif',
'state_paused' => 'Suspendu',
'state_payment_failed' => 'Paiement échoué',
'state_cancelled' => 'Résilié',
'state_deactivated' => 'Désactivé',
'state_pending_payment' => 'En attente du premier paiement',
'state_finished' => 'Terminé',
'state_grace' => 'Geste commercial',
'state_extern_done' => 'Traité en externe',
'state_unknown' => 'Inconnu',
'state_text_active' => 'Fonctionne comme prévu – dernière livraison :last, prochaine livraison :next.',
'state_text_active_first' => 'Fonctionne comme prévu – première livraison le :next.',
'state_text_paused' => 'Suspendu jusqu’au :until – :actor le :date. La prochaine livraison aura lieu le :until.',
'state_text_payment_failed' => 'Le paiement du :date a échoué – l’abonnement ne continue pas tant que le paiement n’est pas régularisé.',
'state_text_payment_error' => 'Motif selon le prestataire de paiement : :error',
'state_text_cancelled' => 'Résilié le :date :actor. Il n’y aura plus de livraison.',
'state_text_deactivated' => 'Désactivé :actor le :date. Aucune livraison tant qu’il n’est pas réactivé.',
'state_text_deactivated_no_date' => 'Désactivé :actor. Aucune livraison tant qu’il n’est pas réactivé.',
'state_text_finished' => 'L’abonnement est terminé.',
'state_text_grace' => 'Geste commercial : livraison débloquée sans paiement réussi.',
'state_text_extern_done' => 'La livraison a été traitée en dehors du système.',
'actor_by_customer' => 'par le client',
'actor_by_consultant' => 'par le conseiller',
'actor_by_admin' => 'par l’administrateur',
'actor_by_system' => 'par le système',
'filter_state' => 'État',
];

View file

@ -25,7 +25,8 @@
<div class="row">
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.status') }} / {{ __('tables.active') }}</div>
{!! $user_abo->getStatusFormated() !!} &nbsp;{!! get_active_badge($user_abo->active) !!}
{!! get_active_badge($user_abo->active) !!}
@include('admin.abo._status_explain', ['abo' => $user_abo])
@if ($isAdmin && $user_abo->status === 3 && $user_abo->active)
<button type="button" class="btn btn-sm btn-danger mt-3" data-toggle="modal"
data-target="#modal-retry-abo-payment">

View file

@ -0,0 +1,16 @@
{{--
Zustand eines Abos mit Erklaerung (Ticket A3). Erwartet $abo (UserAbo).
Badge = Zustand (aktiv / pausiert / Zahlung fehlgeschlagen / gekuendigt / ...),
darunter ein Satz mit Datum, Akteur und ggf. dem Fehlertext der Zahlung.
Mit ['compact' => true] nur Badge und Satz ohne Abstand (Tabellenzellen).
--}}
@php
$statusInfo = app(App\Services\Abo\AboStatusExplainer::class)->explain($abo);
$statusCompact = $compact ?? false;
@endphp
<span class="abo-status-explain" data-state="{{ $statusInfo->state }}">
{!! $statusInfo->badge() !!}
@if ($statusInfo->text !== '')
<div class="small text-muted{{ $statusCompact ? '' : ' mt-1' }}">{{ $statusInfo->text }}</div>
@endif
</span>

View file

@ -18,6 +18,14 @@
@endforeach
</select>
</div>
<div class="col-6 col-md-3 col-lg-2 mb-2">
<label class="form-label" for="filter_state">{{ __('abo.filter_state') }}</label>
<select class="custom-select" name="filter_state" id="filter_state">
<option value="">{{ __('tables.all') }}</option>
<option value="paused" @if(get_user_attr('filter_state') === 'paused') selected @endif>{{ __('abo.state_paused') }}</option>
<option value="payment_failed" @if(get_user_attr('filter_state') === 'payment_failed') selected @endif>{{ __('abo.state_payment_failed') }}</option>
</select>
</div>
<div class="col-6 col-md-3 col-lg-2 mb-2">
<label class="form-label" for="filter_active">{{ __('tables.active') }}</label>
<select class="custom-select" name="filter_active" id="filter_active">
@ -122,6 +130,7 @@
data: function(d) {
d.filter_member_id = $('select[name=filter_member_id]').val();
d.filter_status = $('select[name=filter_status]').val();
d.filter_state = $('select[name=filter_state]').val();
d.filter_active = $('select[name=filter_active]').val();
d.filter_is_for = $('select[name=filter_is_for]').val();
d.filter_payment = $('select[name=filter_payment]').val();
@ -155,7 +164,7 @@
"url": "/js/datatables-{{ \App::getLocale() }}.json"
}
});
$('#filter_start_month, #filter_start_year, #filter_next_day, #filter_status, #filter_active, #filter_is_for, #filter_payment, #filter_member_id').on('change', function(){
$('#filter_start_month, #filter_start_year, #filter_next_day, #filter_status, #filter_state, #filter_active, #filter_is_for, #filter_payment, #filter_member_id').on('change', function(){
oTable.draw();
});
});

View file

@ -145,7 +145,10 @@
<td data-sort-value="{{ $listAbo->getRawOriginal('next_date') }}">{{ $listAbo->next_date }}</td>
<td data-sort-value="{{ $listAbo->getRawOriginal('start_date') }}">{{ $listAbo->start_date }}</td>
<td class="text-right" data-sort-value="{{ $listAbo->getCountPaidOrders() }}">{{ $listAbo->getCountPaidOrders() }}</td>
<td data-sort-value="{{ $listAbo->getStatusType() }}">{!! $listAbo->getStatusFormated() !!} {!! get_active_badge($listAbo->active) !!}</td>
<td data-sort-value="{{ $listAbo->statusInfo()->label }}">
{!! get_active_badge($listAbo->active) !!}
@include('admin.abo._status_explain', ['abo' => $listAbo, 'compact' => true])
</td>
<td class="text-right">
<a href="{{ route('user_abos_detail', ['ot', $listAbo->id]) }}" class="btn btn-sm btn-secondary">
<span class="fa fa-edit"></span> {{ __('abo.abo_details') }}

View file

@ -221,7 +221,8 @@
<div class="row">
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.status') }} / {{ __('tables.active') }}</div>
{!! $user_abo->getStatusFormated() !!} &nbsp;{!! get_active_badge($user_abo->active) !!}
{!! get_active_badge($user_abo->active) !!}
@include('admin.abo._status_explain', ['abo' => $user_abo])
</div>
<div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.abo_delivery') }}</div>

View file

@ -123,7 +123,7 @@
{{ $abo->getFormattedTotalPoints() }} Pkt.
</strong>
</td>
<td>{!! $abo->getStatusFormated() !!}</td>
<td>@include('admin.abo._status_explain', ['abo' => $abo, 'compact' => true])</td>
<td>{{ $abo->next_date }}</td>
<td>{{ $abo->getCountOrders() }}</td>
<td>{!! get_active_badge($abo->active) !!}</td>

View file

@ -0,0 +1,272 @@
<?php
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAboStatusHistory;
use App\Repositories\AboRepository;
use App\Services\Abo\AboStatusExplainer;
use App\Services\Abo\AboStatusInfo;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Ticket A3 (Entwicklungsplan September 2026): Status und Grund eines Abos
* sichtbar machen — Grund am Model (`status_reason`, `paused_until`),
* Fehlertext an der Abo-Bestellung, Erklaerung ueber den Explainer, Anzeige
* ueber das Partial und den Backfill fuer Bestandsdaten.
*/
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow('2026-09-16 10:00:00');
app('request')->setLaravelSession(app('session.store'));
});
afterEach(fn () => Carbon::setTestNow());
function aseUser(): User
{
return User::forceCreate([
'email' => 'ase-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
}
function aseAbo(User $user, array $attributes = [], int $paidDeliveries = 6): UserAbo
{
$abo = UserAbo::create(array_merge([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => 0,
'email' => 'ase-abo-'.uniqid('', true).'@example.com',
'is_for' => 'me',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => '2026-01-05',
'last_date' => '2026-09-05',
'next_date' => '2026-10-05',
], $attributes));
for ($i = 0; $i < $paidDeliveries; $i++) {
UserAboOrder::create(['user_abo_id' => $abo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]);
}
return $abo->fresh();
}
function aseExplain(UserAbo $abo): AboStatusInfo
{
return app(AboStatusExplainer::class)->explain($abo->fresh());
}
it('erklaert ein planmaessig laufendes Abo mit letzter und naechster Lieferung', function () {
$info = aseExplain(aseAbo(aseUser()));
expect($info->state)->toBe(AboStatusInfo::STATE_ACTIVE)
->and($info->color)->toBe('success')
->and($info->text)->toBe(__('abo.state_text_active', ['last' => '05.09.2026', 'next' => '05.10.2026']));
});
it('macht die Pause sichtbar: bis wann, durch wen, seit wann', function () {
$user = aseUser();
$abo = aseAbo($user);
$this->actingAs($user);
$repository = new AboRepository;
$repository->setModel($abo);
expect($repository->update([
'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'portal',
'abo_interval' => 5, 'abo_pause' => 'true',
]))->toBeTrue();
$abo = $abo->fresh();
expect($abo->status_reason)->toBe('paused_by_customer')
->and($abo->paused_until->format('Y-m-d'))->toBe('2026-11-05')
->and($abo->isPaused())->toBeTrue()
->and(UserAbo::paused()->pluck('id')->all())->toBe([$abo->id]);
$info = aseExplain($abo);
expect($info->state)->toBe(AboStatusInfo::STATE_PAUSED)
->and($info->actor)->toBe('customer')
->and($info->text)->toBe(__('abo.state_text_paused', ['until' => '05.11.2026', 'actor' => __('abo.actor_by_customer'), 'date' => '16.09.2026']));
// Ausfuehrung gelaufen (last_date bewegt sich): Pause ist vorbei.
$abo->update(['last_date' => '2026-11-05', 'next_date' => '2026-12-05']);
expect($abo->fresh()->paused_until)->toBeNull()
->and($abo->fresh()->isPaused())->toBeFalse()
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
});
it('leitet die Pause fuer Bestandsdaten ohne paused_until aus den Pausenfeldern ab', function () {
$abo = aseAbo(aseUser(), ['pause_count' => 1, 'last_pause_date' => '2026-09-10', 'last_date' => '2026-09-05']);
expect($abo->pausedUntil()?->format('Y-m-d'))->toBe('2026-10-05')
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_PAUSED);
$delivered = aseAbo(aseUser(), ['pause_count' => 1, 'last_pause_date' => '2026-08-10', 'last_date' => '2026-09-05']);
expect($delivered->pausedUntil())->toBeNull()
->and(aseExplain($delivered)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
});
it('setzt bei Zahlungsfehler den Grund automatisch und zeigt den Fehlertext der Bestellung', function () {
$abo = aseAbo(aseUser());
$abo->update(['status' => 3, 'last_date' => '2026-09-10']);
UserAboOrder::create([
'user_abo_id' => $abo->id, 'shopping_order_id' => 0, 'status' => 3, 'paid' => false,
'error_message' => AboStatusExplainer::paymentErrorMessage(['errorcode' => '905', 'errormessage' => 'Karte abgelaufen', 'customermessage' => 'Ihre Karte ist abgelaufen']),
]);
expect($abo->fresh()->status_reason)->toBe('payment_failed')
->and(UserAbo::paymentFailed()->pluck('id')->all())->toBe([$abo->id]);
$info = aseExplain($abo);
expect($info->state)->toBe(AboStatusInfo::STATE_PAYMENT_FAILED)
->and($info->color)->toBe('danger')
->and($info->errorMessage)->toBe('[905] Ihre Karte ist abgelaufen')
->and($info->text)->toContain(__('abo.state_text_payment_failed', ['date' => '10.09.2026']))
->and($info->text)->toContain('[905] Ihre Karte ist abgelaufen');
// Zahlung nachgeholt: Grund verschwindet mit dem Statuswechsel.
$abo->update(['status' => 2, 'active' => true, 'last_date' => '2026-09-12']);
expect($abo->fresh()->status_reason)->toBeNull()
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
});
it('liefert keinen Fehlertext aus einer leeren oder unlesbaren Zahlungsantwort', function () {
expect(AboStatusExplainer::paymentErrorMessage(null))->toBeNull()
->and(AboStatusExplainer::paymentErrorMessage(['status' => 'ERROR']))->toBeNull()
->and(AboStatusExplainer::paymentErrorMessage(['errormessage' => ' ']))->toBeNull()
->and(AboStatusExplainer::paymentErrorMessage(['errormessage' => 'Deckung nicht ausreichend']))->toBe('Deckung nicht ausreichend')
->and(mb_strlen(AboStatusExplainer::paymentErrorMessage(['errormessage' => str_repeat('x', 400)])))->toBe(255);
});
it('haelt bei der Kuendigung fest, wer gekuendigt hat', function () {
$user = aseUser();
$abo = aseAbo($user);
$this->actingAs($user);
$repository = new AboRepository;
$repository->setModel($abo);
$repository->update([
'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me',
'abo_interval' => 5, 'abo_cancel' => 'true',
]);
$abo = $abo->fresh();
expect((int) $abo->status)->toBe(4)
->and($abo->status_reason)->toBe('cancelled_by_consultant');
$info = aseExplain($abo);
expect($info->state)->toBe(AboStatusInfo::STATE_CANCELLED)
->and($info->actor)->toBe('consultant')
->and($info->text)->toBe(__('abo.state_text_cancelled', ['date' => '16.09.2026', 'actor' => __('abo.actor_by_consultant')]));
});
it('haelt bei der Admin-Deaktivierung den Akteur fest und loescht ihn bei der Reaktivierung', function () {
$admin = aseUser();
$abo = aseAbo($admin);
$this->actingAs($admin);
$repository = new AboRepository;
$repository->setModel($abo);
$repository->update([
'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'admin',
'abo_interval' => 5, 'abo_next_month' => '2026-10', 'abo_is_active' => '',
]);
$abo = $abo->fresh();
expect((int) $abo->status)->toBe(6)
->and($abo->status_reason)->toBe('deactivated_by_admin');
$info = aseExplain($abo);
expect($info->state)->toBe(AboStatusInfo::STATE_DEACTIVATED)
->and($info->text)->toBe(__('abo.state_text_deactivated', ['date' => '16.09.2026', 'actor' => __('abo.actor_by_admin')]));
$repository->setModel($abo);
$repository->update([
'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'admin',
'abo_interval' => 5, 'abo_next_month' => '2026-10', 'abo_is_active' => 'true',
]);
expect($abo->fresh()->status_reason)->toBeNull()
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
});
it('nutzt fuer Bestandsdaten ohne Grund das Statusprotokoll', function () {
$abo = aseAbo(aseUser(), ['status' => 4, 'active' => false, 'cancel_date' => '2026-08-01']);
// Der Grund entstand automatisch beim Anlegen (Status 4 => system); alte Daten haben keinen.
UserAbo::whereKey($abo->id)->update(['status_reason' => null]);
UserAboStatusHistory::create(['user_abo_id' => $abo->id, 'action' => UserAboStatusHistory::ACTION_CANCELLED, 'view' => 'portal']);
$info = aseExplain($abo);
expect($info->actor)->toBe('customer')
->and($info->text)->toContain('01.08.2026')
->and($info->text)->toContain(__('abo.actor_by_customer'));
});
it('erklaert ein Abo, das auf die erste Zahlung wartet', function () {
$info = aseExplain(aseAbo(aseUser(), ['status' => 1], 0));
expect($info->state)->toBe(AboStatusInfo::STATE_PENDING_PAYMENT)
->and($info->label)->toBe(__('abo.state_pending_payment'));
});
it('rendert das Partial mit Badge, Zustand und Erklaerung', function () {
$abo = aseAbo(aseUser(), ['status' => 3, 'last_date' => '2026-09-10']);
$html = view('admin.abo._status_explain', ['abo' => $abo, 'compact' => true])->render();
expect($html)
->toContain('data-state="payment_failed"')
->toContain('badge-danger')
->toContain(__('abo.state_payment_failed'))
->toContain(__('abo.state_text_payment_failed', ['date' => '10.09.2026']));
});
it('traegt Grund und Pausenende fuer Bestandsdaten nach', function () {
$failed = aseAbo(aseUser(), ['status' => 3]);
$cancelled = aseAbo(aseUser(), ['status' => 4, 'active' => false]);
$paused = aseAbo(aseUser(), ['pause_count' => 1, 'last_pause_date' => '2026-09-10', 'last_date' => '2026-09-05']);
$delivered = aseAbo(aseUser(), ['pause_count' => 1, 'last_pause_date' => '2026-08-10', 'last_date' => '2026-09-05']);
$running = aseAbo(aseUser());
UserAbo::whereIn('id', [$failed->id, $cancelled->id, $paused->id])->update(['status_reason' => null, 'paused_until' => null]);
UserAboStatusHistory::create(['user_abo_id' => $cancelled->id, 'action' => UserAboStatusHistory::ACTION_CANCELLED, 'view' => 'ot']);
UserAboStatusHistory::create(['user_abo_id' => $paused->id, 'action' => UserAboStatusHistory::ACTION_PAUSED, 'view' => 'admin']);
$this->artisan('abo:backfill-status-reasons', ['--dry-run' => true])
->expectsOutputToContain('DRY-RUN')
->assertSuccessful();
expect($failed->fresh()->status_reason)->toBeNull();
$this->artisan('abo:backfill-status-reasons')->assertSuccessful();
expect($failed->fresh()->status_reason)->toBe('payment_failed')
->and($cancelled->fresh()->status_reason)->toBe('cancelled_by_consultant')
->and($paused->fresh()->status_reason)->toBe('paused_by_admin')
->and($paused->fresh()->paused_until->format('Y-m-d'))->toBe('2026-10-05')
->and($delivered->fresh()->paused_until)->toBeNull()
->and($running->fresh()->status_reason)->toBeNull();
});