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:
parent
e8057f9f7b
commit
d6efe56cd4
22 changed files with 977 additions and 15 deletions
96
app/Console/Commands/AboBackfillStatusReasons.php
Normal file
96
app/Console/Commands/AboBackfillStatusReasons.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class UserAboOrder extends Model
|
|||
'user_id',
|
||||
'shopping_order_id',
|
||||
'status',
|
||||
'error_message',
|
||||
'paid',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
215
app/Services/Abo/AboStatusExplainer.php
Normal file
215
app/Services/Abo/AboStatusExplainer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
47
app/Services/Abo/AboStatusInfo.php
Normal file
47
app/Services/Abo/AboStatusInfo.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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() !!} {!! 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">
|
||||
|
|
|
|||
16
resources/views/admin/abo/_status_explain.blade.php
Normal file
16
resources/views/admin/abo/_status_explain.blade.php
Normal 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>
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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() !!} {!! 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
272
tests/Feature/AboStatusExplainTest.php
Normal file
272
tests/Feature/AboStatusExplainTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue