From d6efe56cd40b3dd51bc9fdb662dc073947284dca Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 16 Sep 2026 10:24:18 +0000 Subject: [PATCH] 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 --- .../Commands/AboBackfillStatusReasons.php | 96 +++++++ app/Console/Commands/UserMakeAboOrder.php | 10 +- app/Http/Controllers/Admin/AboController.php | 13 +- app/Models/UserAbo.php | 120 ++++++++ app/Models/UserAboOrder.php | 1 + app/Repositories/AboRepository.php | 3 + app/Services/Abo/AboStatusExplainer.php | 215 ++++++++++++++ app/Services/Abo/AboStatusInfo.php | 47 +++ app/Services/AboRetryPaymentService.php | 10 +- ...0_add_status_reason_to_user_abos_table.php | 39 +++ ...twicklungsplan-protokoll-september-2026.md | 17 +- resources/lang/de/abo.php | 28 ++ resources/lang/en/abo.php | 27 ++ resources/lang/es/abo.php | 27 ++ resources/lang/fr/abo.php | 27 ++ resources/views/admin/abo/_detail.blade.php | 3 +- .../views/admin/abo/_status_explain.blade.php | 16 ++ resources/views/admin/abo/index.blade.php | 11 +- .../user/abo/_customer_abo_list.blade.php | 5 +- resources/views/user/abo/index.blade.php | 3 +- .../views/user/team/customer_abos.blade.php | 2 +- tests/Feature/AboStatusExplainTest.php | 272 ++++++++++++++++++ 22 files changed, 977 insertions(+), 15 deletions(-) create mode 100644 app/Console/Commands/AboBackfillStatusReasons.php create mode 100644 app/Services/Abo/AboStatusExplainer.php create mode 100644 app/Services/Abo/AboStatusInfo.php create mode 100644 database/migrations/2026_09_16_120000_add_status_reason_to_user_abos_table.php create mode 100644 resources/views/admin/abo/_status_explain.blade.php create mode 100644 tests/Feature/AboStatusExplainTest.php diff --git a/app/Console/Commands/AboBackfillStatusReasons.php b/app/Console/Commands/AboBackfillStatusReasons.php new file mode 100644 index 00000000..238d21f4 --- /dev/null +++ b/app/Console/Commands/AboBackfillStatusReasons.php @@ -0,0 +1,96 @@ +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); + } +} diff --git a/app/Console/Commands/UserMakeAboOrder.php b/app/Console/Commands/UserMakeAboOrder.php index 42f40387..43d66d68 100644 --- a/app/Console/Commands/UserMakeAboOrder.php +++ b/app/Console/Commands/UserMakeAboOrder.php @@ -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, ]); diff --git a/app/Http/Controllers/Admin/AboController.php b/app/Http/Controllers/Admin/AboController.php index 5073a30c..00b5e1c0 100644 --- a/app/Http/Controllers/Admin/AboController.php +++ b/app/Http/Controllers/Admin/AboController.php @@ -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); diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index b14f1774..f7b323b8 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -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', diff --git a/app/Models/UserAboOrder.php b/app/Models/UserAboOrder.php index 1e4915fa..93f7f9e1 100644 --- a/app/Models/UserAboOrder.php +++ b/app/Models/UserAboOrder.php @@ -49,6 +49,7 @@ class UserAboOrder extends Model 'user_id', 'shopping_order_id', 'status', + 'error_message', 'paid', ]; diff --git a/app/Repositories/AboRepository.php b/app/Repositories/AboRepository.php index a598e0c5..3bc4d76e 100644 --- a/app/Repositories/AboRepository.php +++ b/app/Repositories/AboRepository.php @@ -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); diff --git a/app/Services/Abo/AboStatusExplainer.php b/app/Services/Abo/AboStatusExplainer.php new file mode 100644 index 00000000..955d4346 --- /dev/null +++ b/app/Services/Abo/AboStatusExplainer.php @@ -0,0 +1,215 @@ +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|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; + } +} diff --git a/app/Services/Abo/AboStatusInfo.php b/app/Services/Abo/AboStatusInfo.php new file mode 100644 index 00000000..6ec5ed0a --- /dev/null +++ b/app/Services/Abo/AboStatusInfo.php @@ -0,0 +1,47 @@ +color).'">'.e($this->label).''; + } +} diff --git a/app/Services/AboRetryPaymentService.php b/app/Services/AboRetryPaymentService.php index e2f5a01c..04845894 100644 --- a/app/Services/AboRetryPaymentService.php +++ b/app/Services/AboRetryPaymentService.php @@ -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, ]); }); diff --git a/database/migrations/2026_09_16_120000_add_status_reason_to_user_abos_table.php b/database/migrations/2026_09_16_120000_add_status_reason_to_user_abos_table.php new file mode 100644 index 00000000..a703927e --- /dev/null +++ b/database/migrations/2026_09_16_120000_add_status_reason_to_user_abos_table.php @@ -0,0 +1,39 @@ +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'); + }); + } +}; diff --git a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md index eb65b02f..0aa02946 100644 --- a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md +++ b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md @@ -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. diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 02b2f83a..c9c2ed42 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -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', ]; diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 70e84cf8..5fe86e28 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -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', ]; diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index 3a023235..fab5d260 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -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', ]; diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php index e459e6fa..92fd9cda 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -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', ]; diff --git a/resources/views/admin/abo/_detail.blade.php b/resources/views/admin/abo/_detail.blade.php index 80b4552c..1bdac809 100644 --- a/resources/views/admin/abo/_detail.blade.php +++ b/resources/views/admin/abo/_detail.blade.php @@ -25,7 +25,8 @@
{{ __('tables.status') }} / {{ __('tables.active') }}
- {!! $user_abo->getStatusFormated() !!}  {!! 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)
+
+ + +