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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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