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>
215 lines
8.2 KiB
PHP
215 lines
8.2 KiB
PHP
<?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;
|
||
}
|
||
}
|