Abo (A4): Abschnitt "Abo pausieren oder kuendigen" mit Kuendigungs-Hinweis
Eigener Abschnitt auf der Abo-Seite (CRM me/ot und Kundenportal) mit Fortschritt zur Mindestlaufzeit, Pause und Kuendigung. Die Kuendigung laeuft ueber ein Modal, das die Folgen nennt (Abo endet, Konditionen entfallen, ein neues Abo hat wieder 6 Pflichtlieferungen), die Pause als Alternative anbietet und erst nach Bestaetigungs-Checkbox freigibt. Innerhalb der Sperrfrist vor der Ausfuehrung erscheint statt der Buttons ein Hinweis (AboHelper::isPauseCancelLocked). Das Einstellungs-Modal verweist nur noch auf den Abschnitt. Die Kuendigung im Repository ist jetzt wie die Pause eine eigenstaendige Aktion: Erfolgsmeldung statt Liefertermin-Hinweis, next_date bleibt unangetastet. Sprachdateien de/en/es/fr, Tests in AboPauseCancelSectionTest, Plan-Status. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
74aefa9640
commit
e8057f9f7b
12 changed files with 526 additions and 4 deletions
|
|
@ -68,6 +68,21 @@ class AboRepository extends BaseRepository
|
|||
return $this->pauseOneMonth($data);
|
||||
}
|
||||
|
||||
// Kündigung ist wie die Pause eine eigenständige Aktion: nach dem
|
||||
// Statuswechsel gibt es keinen nächsten Liefertermin mehr, der
|
||||
// neu berechnet oder als Hinweis ausgegeben werden müsste.
|
||||
if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') {
|
||||
$this->updateStatus($data);
|
||||
|
||||
if ((int) $this->model->status === 4) {
|
||||
\Session()->flash('alert-success', __('abo.cancel_success'));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->updateStatus($data);
|
||||
$this->model->abo_interval = (int) $data['abo_interval'];
|
||||
$nextDate = $this->isAdminUpdate($data)
|
||||
|
|
|
|||
|
|
@ -212,6 +212,25 @@ class AboHelper
|
|||
return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob Pausieren/Kündigen aktuell durch die Sperrfrist vor der
|
||||
* Ausführung blockiert ist (Setting `abo-lock-days-pause-cancel`). Admins
|
||||
* umgehen die Sperre. Spiegelt die serverseitige Prüfung in
|
||||
* {@see AboRepository::updateStatus()} bzw. `pauseOneMonthLocked()` für die
|
||||
* UI, damit der Abschnitt "Abo pausieren oder kündigen" den Hinweis statt
|
||||
* der Buttons zeigt.
|
||||
*/
|
||||
public static function isPauseCancelLocked(UserAbo $userAbo, string $view = 'user'): bool
|
||||
{
|
||||
if ($view === 'admin' || ! $userAbo->next_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false);
|
||||
|
||||
return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysPauseCancel();
|
||||
}
|
||||
|
||||
public static function canEditAbo($user_abo, $view = 'user')
|
||||
{
|
||||
if ($view === 'portal') {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026
|
||||
|
||||
**Stand:** 14.09.2026 · **Status:** C1 (Team-Statistik) umgesetzt, Deploy offen; nächstes Paket: Abo (A4 → A3 → A1+A2)
|
||||
**Stand:** 16.09.2026 · **Status:** C1/C2 live seit 14.09., A4 umgesetzt (Commit, Deploy offen); als Nächstes A3, dann 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)
|
||||
|
||||
|
|
@ -13,7 +13,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 | – |
|
||||
| **A4** | Pausieren/Kündigen beschriften, Kündigungs-Hinweis (6 Ausführungen) | Ticket #3, Protokoll | 1 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 |
|
||||
| **B2** | Berater fällt aus: Pausieren nach 2 Tagen, Rückgabe bei Zahlung, harter Cut nach 4 Wochen, Sponsor-Mail | Protokoll, Rückmeldung 14.09. | 3–4 PT | mittel (einmaliger Massen-Effekt, akzeptiert) | – |
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
|
||||
**Risiko:** niedrig. Reine Zusatzinformation, keine Verhaltensänderung.
|
||||
|
||||
### A4 – Pausieren/Kündigen beschriften, Kündigungs-Hinweis
|
||||
### 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.
|
||||
|
||||
|
|
@ -135,6 +135,15 @@
|
|||
|
||||
**Risiko:** keins. 1 PT.
|
||||
|
||||
**Umsetzung 16.09.2026 (Commit auf `payment/phase-r-payone-refactoring`):**
|
||||
|
||||
- Neues Partial `resources/views/admin/abo/_pause_cancel.blade.php`, eingebunden unter den Ausführungen auf der CRM-Abo-Seite (`user/abo/detail`, Sicht me und ot) und im Kundenportal (`portal/abo/my_abo`). Zeigt Fortschritt „X von 6 Lieferungen ausgeführt“ (Balken), vor der Mindestlaufzeit nur den Hinweis auf die restlichen Lieferungen, danach die Pause (Formular, bestehende Regeln) und den Kündigen-Button. Innerhalb der Sperrfrist vor der Ausführung (`abo-lock-days-pause-cancel`) erscheint statt der Buttons ein Hinweis mit Lieferdatum — dafür neu `AboHelper::isPauseCancelLocked()`, spiegelt die Prüfung aus `AboRepository`.
|
||||
- Kündigung über Bootstrap-Modal statt `confirm()`: drei Folgen (Abo endet sofort, Konditionen/Punkte entfallen, neues Abo wieder 6 Pflichtlieferungen), Hinweis „lieber pausieren“, Button „Lieber 1 Monat pausieren“ als primäre Aktion (nur wenn die Pause gerade möglich ist), „Trotzdem kündigen“ erst nach Bestätigungs-Checkbox aktiv.
|
||||
- Einstellungs-Modal: die Pause bleibt dort, der Kündigen-Button schließt das Modal und springt zum neuen Abschnitt (kein zweiter Kündigungspfad mehr).
|
||||
- `AboRepository`: Kündigung ist wie die Pause eine eigenständige Aktion — Erfolgsmeldung `abo.cancel_success`, kein Neuberechnen von `next_date` und kein „nächste Ausführung in N Tagen“-Hinweis mehr nach der Kündigung. Server-Regeln (Mindestlaufzeit, Sperrfrist) unverändert.
|
||||
- Sprachdateien de/en/es/fr (16 neue Keys `pause_cancel_*`, `cancel_*`). Tests: `AboPauseCancelSectionTest` (9 Tests), bestehende `AboPauseTest`, `AboStatusGuardTest`, `AboUpdateAuthorizationTest`, `AboLockDaysSettingTest` grün.
|
||||
- **Deploy:** Dateien hochladen, `php artisan view:clear`. Keine Migration, kein Setting, keine Daten. `LocalizationSyncTest` schlägt auf dem Branch weiterhin wegen `msg.php` (Berater-Übertragung, nicht committet) und `payment.php` (es/fr seit `2e71173` unvollständig) fehl — nicht Teil von A4.
|
||||
|
||||
### A5 – Kundenabo → Partnerabo automatisch umwandeln
|
||||
|
||||
**Ziel (Protokoll):** Nach der Partnerregistrierung erkennt das System ein laufendes Kundenabo → Umwandlung anbieten, Zahlung neu bestätigen, Kompensationsprodukte wählen, **Mindestlaufzeit läuft weiter** (3 Lieferungen als Kunde + Monat 4 als Partner).
|
||||
|
|
|
|||
|
|
@ -330,4 +330,22 @@ return [
|
|||
'payment_method_change_link_mail_subject' => 'Zahlungsart für dein Abo aktualisieren',
|
||||
'payment_method_change_link_mail_intro' => 'Bitte aktualisiere die Zahlungsart für dein Abo über den folgenden sicheren Link. Deine neue Zahlungsart wählst du direkt auf der sicheren Seite unseres Zahlungsdienstleisters Stripe — es wird dabei nichts abgebucht.',
|
||||
'payment_method_change_link_mail_button' => 'Zahlungsart aktualisieren',
|
||||
|
||||
// Abschnitt "Abo pausieren oder kündigen" (Ticket A4)
|
||||
'pause_cancel_title' => 'Abo pausieren oder kündigen',
|
||||
'pause_cancel_intro' => 'Hier kannst du dein Abo für einen Monat aussetzen oder dauerhaft beenden. Beides ist ab der :min. Lieferung möglich.',
|
||||
'pause_cancel_progress' => ':count von :min Lieferungen ausgeführt',
|
||||
'pause_cancel_locked_hint' => 'Die nächste Lieferung am :date wird bereits vorbereitet (Sperre :lock Tage vorher). Pausieren oder Kündigen ist erst nach dieser Lieferung wieder möglich.',
|
||||
'cancel_short_copy' => 'Beendet das Abo dauerhaft. Ein neues Abo beginnt wieder mit :min Pflichtlieferungen.',
|
||||
'cancel_in_section_hint' => 'Die Kündigung findest du unten auf der Abo-Seite im Abschnitt „Abo pausieren oder kündigen“.',
|
||||
'cancel_modal_title' => 'Abo wirklich kündigen?',
|
||||
'cancel_consequences_intro' => 'Bitte beachte, was eine Kündigung bedeutet:',
|
||||
'cancel_consequence_end' => 'Das Abo endet sofort – es gibt keine weitere Lieferung.',
|
||||
'cancel_consequence_points' => 'Die Abo-Konditionen und die damit verbundenen Punkte entfallen.',
|
||||
'cancel_consequence_restart' => 'Ein neues Abo beginnt wieder von vorn: erst nach :min Pflichtlieferungen kann es pausiert oder gekündigt werden.',
|
||||
'cancel_alternative_pause' => 'Tipp: Wenn du nur eine Lieferung aussetzen möchtest, pausiere das Abo lieber für einen Monat. Es bleibt dann mit allen Konditionen bestehen.',
|
||||
'cancel_prefer_pause_button' => 'Lieber 1 Monat pausieren',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -330,4 +330,21 @@ return [
|
|||
'payment_method_change_link_mail_subject' => 'Update the payment method for your subscription',
|
||||
'payment_method_change_link_mail_intro' => 'Please update the payment method for your subscription via the following secure link. You choose your new payment method directly on the secure page of our payment provider Stripe — nothing will be charged.',
|
||||
'payment_method_change_link_mail_button' => 'Update payment method',
|
||||
|
||||
'pause_cancel_title' => 'Pause or cancel subscription',
|
||||
'pause_cancel_intro' => 'Here you can pause your subscription for one month or end it permanently. Both are possible from the :min. delivery on.',
|
||||
'pause_cancel_progress' => ':count of :min deliveries completed',
|
||||
'pause_cancel_locked_hint' => 'The next delivery on :date is already being prepared (locked :lock days before). Pausing or cancelling is possible again after this delivery.',
|
||||
'cancel_short_copy' => 'Ends the subscription permanently. A new subscription starts again with :min mandatory deliveries.',
|
||||
'cancel_in_section_hint' => 'You will find the cancellation at the bottom of the subscription page in the section "Pause or cancel subscription".',
|
||||
'cancel_modal_title' => 'Really cancel the subscription?',
|
||||
'cancel_consequences_intro' => 'Please note what a cancellation means:',
|
||||
'cancel_consequence_end' => 'The subscription ends immediately – there will be no further delivery.',
|
||||
'cancel_consequence_points' => 'The subscription conditions and the associated points are lost.',
|
||||
'cancel_consequence_restart' => 'A new subscription starts from scratch: it can only be paused or cancelled after :min mandatory deliveries.',
|
||||
'cancel_alternative_pause' => 'Tip: If you only want to skip one delivery, pause the subscription for one month instead. It then continues with all its conditions.',
|
||||
'cancel_prefer_pause_button' => 'Rather pause for 1 month',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -330,4 +330,21 @@ return [
|
|||
'payment_method_change_link_mail_subject' => 'Actualice el método de pago de su suscripción',
|
||||
'payment_method_change_link_mail_intro' => 'Actualice el método de pago de su suscripción a través del siguiente enlace seguro. Elegirá su nuevo método de pago directamente en la página segura de nuestro proveedor de pagos Stripe; no se cobrará nada.',
|
||||
'payment_method_change_link_mail_button' => 'Actualizar método de pago',
|
||||
|
||||
'pause_cancel_title' => 'Pausar o cancelar la suscripción',
|
||||
'pause_cancel_intro' => 'Aquí puedes pausar tu suscripción durante un mes o finalizarla definitivamente. Ambas opciones están disponibles a partir de la :min.ª entrega.',
|
||||
'pause_cancel_progress' => ':count de :min entregas realizadas',
|
||||
'pause_cancel_locked_hint' => 'La próxima entrega del :date ya se está preparando (bloqueo :lock días antes). Pausar o cancelar volverá a ser posible después de esta entrega.',
|
||||
'cancel_short_copy' => 'Finaliza la suscripción definitivamente. Una nueva suscripción vuelve a empezar con :min entregas obligatorias.',
|
||||
'cancel_in_section_hint' => 'Encontrarás la cancelación al final de la página de la suscripción, en la sección «Pausar o cancelar la suscripción».',
|
||||
'cancel_modal_title' => '¿Realmente quieres cancelar la suscripción?',
|
||||
'cancel_consequences_intro' => 'Ten en cuenta lo que implica una cancelación:',
|
||||
'cancel_consequence_end' => 'La suscripción termina de inmediato: no habrá más entregas.',
|
||||
'cancel_consequence_points' => 'Se pierden las condiciones de la suscripción y los puntos asociados.',
|
||||
'cancel_consequence_restart' => 'Una nueva suscripción empieza desde cero: solo podrá pausarse o cancelarse después de :min entregas obligatorias.',
|
||||
'cancel_alternative_pause' => 'Consejo: si solo quieres saltarte una entrega, pausa la suscripción un mes. Así se mantiene con todas sus condiciones.',
|
||||
'cancel_prefer_pause_button' => 'Mejor pausar 1 mes',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -330,4 +330,21 @@ return [
|
|||
'payment_method_change_link_mail_subject' => 'Mettez à jour le moyen de paiement de votre abonnement',
|
||||
'payment_method_change_link_mail_intro' => 'Veuillez mettre à jour le moyen de paiement de votre abonnement via le lien sécurisé suivant. Vous choisissez votre nouveau moyen de paiement directement sur la page sécurisée de notre prestataire de paiement Stripe — rien ne sera débité.',
|
||||
'payment_method_change_link_mail_button' => 'Mettre à jour le moyen de paiement',
|
||||
|
||||
'pause_cancel_title' => 'Suspendre ou résilier l’abonnement',
|
||||
'pause_cancel_intro' => 'Ici, tu peux suspendre ton abonnement pendant un mois ou y mettre fin définitivement. Les deux sont possibles à partir de la :min. livraison.',
|
||||
'pause_cancel_progress' => ':count livraisons sur :min effectuées',
|
||||
'pause_cancel_locked_hint' => 'La prochaine livraison du :date est déjà en préparation (blocage :lock jours avant). Suspendre ou résilier sera de nouveau possible après cette livraison.',
|
||||
'cancel_short_copy' => 'Met fin définitivement à l’abonnement. Un nouvel abonnement recommence avec :min livraisons obligatoires.',
|
||||
'cancel_in_section_hint' => 'Tu trouveras la résiliation en bas de la page de l’abonnement, dans la section « Suspendre ou résilier l’abonnement ».',
|
||||
'cancel_modal_title' => 'Vraiment résilier l’abonnement ?',
|
||||
'cancel_consequences_intro' => 'Merci de noter ce qu’implique une résiliation :',
|
||||
'cancel_consequence_end' => 'L’abonnement prend fin immédiatement – il n’y aura plus de livraison.',
|
||||
'cancel_consequence_points' => 'Les conditions de l’abonnement et les points associés sont perdus.',
|
||||
'cancel_consequence_restart' => 'Un nouvel abonnement repart de zéro : il ne pourra être suspendu ou résilié qu’après :min livraisons obligatoires.',
|
||||
'cancel_alternative_pause' => 'Conseil : si tu veux seulement sauter une livraison, suspends plutôt l’abonnement pendant un mois. Il continue alors avec toutes ses conditions.',
|
||||
'cancel_prefer_pause_button' => 'Plutôt suspendre 1 mois',
|
||||
'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é.',
|
||||
];
|
||||
|
|
|
|||
169
resources/views/admin/abo/_pause_cancel.blade.php
Normal file
169
resources/views/admin/abo/_pause_cancel.blade.php
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
{{--
|
||||
Abschnitt "Abo pausieren oder kündigen" (Ticket A4, Entwicklungsplan September 2026).
|
||||
Wird auf der Abo-Detailseite im CRM (view me/ot) und im Kundenportal (view portal)
|
||||
eingebunden. Erwartet $user_abo und $view. Beide Aktionen posten wie das
|
||||
Einstellungs-Modal auf `user_abos_update` (action abo_update_settings); der
|
||||
aktuelle Liefertag wird unverändert mitgesendet, damit die Liefertag-Validierung
|
||||
nicht greift.
|
||||
--}}
|
||||
@php
|
||||
$pcStatus = (int) $user_abo->status;
|
||||
$pcEnded = in_array($pcStatus, App\Services\AboHelper::ENDED_STATUSES, true);
|
||||
$pcMinDuration = (int) App\Services\AboHelper::getAboMinDuration();
|
||||
$pcPaidOrders = (int) $user_abo->getCountPaidOrders();
|
||||
$pcCanCancel = App\Services\AboHelper::canCancelAbo($user_abo, $view);
|
||||
$pcCanPause = App\Services\AboHelper::canPauseAbo($user_abo, $view);
|
||||
$pcNextPossiblePause = App\Services\AboHelper::nextPossiblePauseDate($user_abo);
|
||||
$pcLocked = App\Services\AboHelper::isPauseCancelLocked($user_abo, $view);
|
||||
$pcProgress = $pcMinDuration > 0 ? min(100, (int) round($pcPaidOrders / $pcMinDuration * 100)) : 100;
|
||||
$pcUpdateRoute = route('user_abos_update', [$view, $user_abo->id]);
|
||||
$pcNextDate = $user_abo->next_date ? \Carbon\Carbon::parse($user_abo->next_date) : null;
|
||||
@endphp
|
||||
|
||||
@if ($pcStatus >= 2 && ! $pcEnded)
|
||||
<div class="card mt-3 mb-3" id="abo-pause-cancel">
|
||||
<div class="card-body">
|
||||
<h6 class="font-weight-bold">
|
||||
<i class="ion ion-md-pause text-muted mr-1"></i>{{ __('abo.pause_cancel_title') }}
|
||||
</h6>
|
||||
<p class="text-muted mb-3">{{ __('abo.pause_cancel_intro', ['min' => $pcMinDuration]) }}</p>
|
||||
|
||||
<div class="mb-1 small">
|
||||
{{ __('abo.pause_cancel_progress', ['count' => $pcPaidOrders, 'min' => $pcMinDuration]) }}
|
||||
</div>
|
||||
<div class="progress mb-3" style="height: 8px;">
|
||||
<div class="progress-bar {{ $pcCanCancel ? 'bg-success' : 'bg-primary' }}" role="progressbar"
|
||||
style="width: {{ $pcProgress }}%;" aria-valuenow="{{ $pcPaidOrders }}" aria-valuemin="0"
|
||||
aria-valuemax="{{ $pcMinDuration }}"></div>
|
||||
</div>
|
||||
|
||||
@if (! $pcCanCancel)
|
||||
<div class="alert alert-info mb-0">
|
||||
{{ __('abo.info_min_duration_orders_left', ['count' => max(0, $pcMinDuration - $pcPaidOrders)]) }}
|
||||
</div>
|
||||
@elseif ($pcLocked && $pcNextDate)
|
||||
<div class="alert alert-warning mb-0">
|
||||
{{ __('abo.pause_cancel_locked_hint', [
|
||||
'date' => $pcNextDate->format(Util::formatDateDB()),
|
||||
'lock' => App\Repositories\AboRepository::getLockDaysPauseCancel(),
|
||||
]) }}
|
||||
</div>
|
||||
@else
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="font-weight-semibold mb-1">{{ __('abo.pause_abo') }}</div>
|
||||
<p class="text-muted small mb-2">
|
||||
{{ __('abo.abo_copy_pause', ['months' => App\Services\AboHelper::getPauseIntervalMonths()]) }}
|
||||
</p>
|
||||
@if ($pcCanPause)
|
||||
<form method="POST" action="{{ $pcUpdateRoute }}" class="d-inline" id="abo-pause-form">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="abo_update_settings">
|
||||
<input type="hidden" name="id" value="{{ $user_abo->id }}">
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="abo_interval" value="{{ $user_abo->abo_interval }}">
|
||||
<input type="hidden" name="abo_is_active" value="{{ $user_abo->active }}">
|
||||
<input type="hidden" name="abo_pause" value="true">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm"
|
||||
onclick="return confirm('{{ __('abo.confirm_pause') }}')">
|
||||
<i class="ion ion-md-pause"></i> {{ __('abo.pause_abo') }}
|
||||
</button>
|
||||
</form>
|
||||
@elseif ($pcNextPossiblePause)
|
||||
<div class="text-muted small">
|
||||
{{ __('abo.info_pause_available_from', ['date' => $pcNextPossiblePause->format(Util::formatDateDB()), 'months' => App\Services\AboHelper::getPauseIntervalMonths()]) }}
|
||||
</div>
|
||||
@else
|
||||
<div class="text-muted small">{{ __('abo.error_pause_not_active') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="font-weight-semibold mb-1">{{ __('abo.cancel_abo') }}</div>
|
||||
<p class="text-muted small mb-2">{{ __('abo.cancel_short_copy', ['min' => $pcMinDuration]) }}</p>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-toggle="modal"
|
||||
data-target="#modal-abo-cancel">
|
||||
<i class="ion ion-ios-trash"></i> {{ __('abo.cancel_abo') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($pcCanCancel && ! $pcLocked)
|
||||
<div class="modal fade" id="modal-abo-cancel" tabindex="-1" role="dialog" aria-labelledby="modal-abo-cancel-label"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ $pcUpdateRoute }}" id="abo-cancel-form">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="abo_update_settings">
|
||||
<input type="hidden" name="id" value="{{ $user_abo->id }}">
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="abo_interval" value="{{ $user_abo->abo_interval }}">
|
||||
<input type="hidden" name="abo_is_active" value="{{ $user_abo->active }}">
|
||||
<input type="hidden" name="abo_cancel" value="true">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-abo-cancel-label">{{ __('abo.cancel_modal_title') }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger">
|
||||
<strong>{{ __('abo.cancel_consequences_intro') }}</strong>
|
||||
<ul class="mb-0 mt-1 pl-3">
|
||||
<li>{{ __('abo.cancel_consequence_end') }}</li>
|
||||
<li>{{ __('abo.cancel_consequence_points') }}</li>
|
||||
<li>{{ __('abo.cancel_consequence_restart', ['min' => $pcMinDuration]) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@if ($pcCanPause)
|
||||
<div class="alert alert-info">
|
||||
{{ __('abo.cancel_alternative_pause') }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" id="abo-cancel-confirm" name="abo_cancel_confirm" value="1">
|
||||
<label class="custom-control-label" for="abo-cancel-confirm">
|
||||
{{ __('abo.cancel_confirm_checkbox', ['min' => $pcMinDuration]) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||
@if ($pcCanPause)
|
||||
<button type="button" class="btn btn-primary" id="abo-cancel-prefer-pause">
|
||||
<i class="ion ion-md-pause"></i> {{ __('abo.cancel_prefer_pause_button') }}
|
||||
</button>
|
||||
@endif
|
||||
<button type="submit" class="btn btn-outline-danger" id="abo-cancel-submit" disabled>
|
||||
{{ __('abo.cancel_anyway_button') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
var confirmBox = $('#abo-cancel-confirm');
|
||||
var submitButton = $('#abo-cancel-submit');
|
||||
confirmBox.on('change', function () {
|
||||
submitButton.prop('disabled', !confirmBox.is(':checked'));
|
||||
});
|
||||
$('#modal-abo-cancel').on('hidden.bs.modal', function () {
|
||||
confirmBox.prop('checked', false);
|
||||
submitButton.prop('disabled', true);
|
||||
});
|
||||
$('#abo-cancel-prefer-pause').on('click', function () {
|
||||
$('#modal-abo-cancel').modal('hide');
|
||||
if (confirm('{{ __('abo.confirm_pause') }}')) {
|
||||
$('#abo-pause-form').trigger('submit');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
@endif
|
||||
|
|
@ -133,11 +133,18 @@
|
|||
<hr>
|
||||
@endif
|
||||
|
||||
{{-- Kündigen läuft über den Abschnitt "Abo pausieren oder kündigen" auf der
|
||||
Abo-Seite (mit Folgen-Hinweis und Pause als Alternative, Ticket A4).
|
||||
Der Button hier schließt das Modal und springt dorthin. --}}
|
||||
<div class="form-row">
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" name="abo_cancel" value="true" class="btn btn-outline-danger btn-sm" onclick="return confirm('{{ __('abo.confirm_cancel') }}')">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
onclick="$('#modals-load-content').modal('hide'); var s = document.getElementById('abo-pause-cancel'); if (s) { s.scrollIntoView({behavior: 'smooth'}); }">
|
||||
<i class="ion ion-ios-trash"></i> {{ __('abo.cancel_abo') }}
|
||||
</button>
|
||||
<div class="mt-1">
|
||||
<i class="text-muted">{{ __('abo.cancel_in_section_hint') }}</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@
|
|||
@include('admin.abo._executions')
|
||||
</div>
|
||||
|
||||
@include('admin.abo._pause_cancel')
|
||||
|
||||
@php($canStartPaymentChange = app(\App\Services\AboPaymentMethodService::class)->canStartChange($user_abo))
|
||||
@if ($user_abo->isCreditCard() || $canStartPaymentChange)
|
||||
<div class="card mt-3 mb-3">
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@
|
|||
@include('admin.abo._executions')
|
||||
</div>
|
||||
|
||||
@include('admin.abo._pause_cancel')
|
||||
|
||||
@php($canStartPaymentChange = app(\App\Services\AboPaymentMethodService::class)->canStartChange($user_abo))
|
||||
@if ($user_abo->isCreditCard() || $canStartPaymentChange)
|
||||
<div class="card mt-3 mb-3">
|
||||
|
|
|
|||
230
tests/Feature/AboPauseCancelSectionTest.php
Normal file
230
tests/Feature/AboPauseCancelSectionTest.php
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Repositories\AboRepository;
|
||||
use App\Services\AboHelper;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Ticket A4 (Entwicklungsplan September 2026): eigener Abschnitt "Abo pausieren
|
||||
* oder kündigen" auf der Abo-Seite (CRM und Portal) mit Fortschritt zur
|
||||
* Mindestlaufzeit, Kündigungs-Modal samt Folgen-Hinweis und Pause als
|
||||
* Alternative; die Kündigung im Einstellungs-Modal verweist nur noch dorthin.
|
||||
*/
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-09-16 10:00:00');
|
||||
app('request')->setLaravelSession(app('session.store'));
|
||||
URL::resolveMissingNamedRoutesUsing(fn ($name, $parameters = []) => '/'.$name.'/'.implode('/', (array) $parameters));
|
||||
});
|
||||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
function pcsUser(): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'email' => 'pcs-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'payment_account' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
||||
function pcsAbo(User $user, int $paidDeliveries = 6, string $nextDate = '2026-10-05', array $attributes = []): UserAbo
|
||||
{
|
||||
$abo = UserAbo::create(array_merge([
|
||||
'user_id' => $user->id,
|
||||
'member_id' => $user->id,
|
||||
'shopping_user_id' => 0,
|
||||
'email' => 'pcs-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' => $nextDate,
|
||||
], $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 pcsRender(UserAbo $abo, string $view = 'me'): string
|
||||
{
|
||||
return view('admin.abo._pause_cancel', ['user_abo' => $abo, 'view' => $view])->render();
|
||||
}
|
||||
|
||||
it('zeigt nach der Mindestlaufzeit Pause und Kuendigung mit Modal, Folgen-Hinweis und Bestaetigungs-Checkbox', function () {
|
||||
$abo = pcsAbo(pcsUser());
|
||||
|
||||
$html = pcsRender($abo);
|
||||
|
||||
expect($html)
|
||||
->toContain('id="abo-pause-cancel"')
|
||||
->toContain(__('abo.pause_cancel_title'))
|
||||
->toContain(__('abo.pause_cancel_progress', ['count' => 6, 'min' => 6]))
|
||||
->toContain('id="abo-pause-form"')
|
||||
->toContain('name="abo_pause" value="true"')
|
||||
->toContain('id="modal-abo-cancel"')
|
||||
->toContain('name="abo_cancel" value="true"')
|
||||
->toContain(__('abo.cancel_consequence_restart', ['min' => 6]))
|
||||
->toContain(__('abo.cancel_prefer_pause_button'))
|
||||
->toContain(__('abo.cancel_anyway_button'))
|
||||
->toContain('id="abo-cancel-confirm"')
|
||||
->toContain('id="abo-cancel-submit" disabled')
|
||||
->toContain('/user_abos_update/me/'.$abo->id)
|
||||
->not->toContain(__('abo.info_min_duration_orders_left', ['count' => 0]));
|
||||
});
|
||||
|
||||
it('zeigt vor der Mindestlaufzeit nur den Fortschritt und den Hinweis auf die verbleibenden Lieferungen', function () {
|
||||
$abo = pcsAbo(pcsUser(), paidDeliveries: 4);
|
||||
|
||||
$html = pcsRender($abo, 'portal');
|
||||
|
||||
expect($html)
|
||||
->toContain(__('abo.pause_cancel_progress', ['count' => 4, 'min' => 6]))
|
||||
->toContain(__('abo.info_min_duration_orders_left', ['count' => 2]))
|
||||
->not->toContain('id="modal-abo-cancel"')
|
||||
->not->toContain('name="abo_cancel"')
|
||||
->not->toContain('name="abo_pause"');
|
||||
});
|
||||
|
||||
it('zeigt innerhalb der Sperrfrist vor der Ausfuehrung den Hinweis statt der Buttons', function () {
|
||||
// Ausfuehrung in 2 Tagen, Sperre pausieren/kuendigen = 3 Tage
|
||||
$abo = pcsAbo(pcsUser(), nextDate: '2026-09-18');
|
||||
|
||||
expect(AboHelper::isPauseCancelLocked($abo, 'me'))->toBeTrue()
|
||||
->and(AboHelper::isPauseCancelLocked($abo, 'admin'))->toBeFalse();
|
||||
|
||||
$html = pcsRender($abo);
|
||||
|
||||
expect($html)
|
||||
->toContain(__('abo.pause_cancel_locked_hint', ['date' => '18.09.2026', 'lock' => AboRepository::getLockDaysPauseCancel()]))
|
||||
->not->toContain('id="modal-abo-cancel"')
|
||||
->not->toContain('name="abo_pause"');
|
||||
});
|
||||
|
||||
it('bietet im Kuendigungs-Modal keine Pause an, wenn die Pause gerade nicht moeglich ist', function () {
|
||||
$abo = pcsAbo(pcsUser(), attributes: ['last_pause_date' => '2026-08-01']);
|
||||
|
||||
$html = pcsRender($abo);
|
||||
|
||||
expect($html)
|
||||
->toContain('id="modal-abo-cancel"')
|
||||
->toContain(__('abo.info_pause_available_from', ['date' => '01.02.2027', 'months' => 6]))
|
||||
->not->toContain('id="abo-pause-form"')
|
||||
->not->toContain(__('abo.cancel_prefer_pause_button'))
|
||||
->not->toContain(__('abo.cancel_alternative_pause'));
|
||||
});
|
||||
|
||||
it('blendet den Abschnitt fuer beendete Abos aus', function () {
|
||||
$cancelled = pcsAbo(pcsUser(), attributes: ['status' => 4, 'active' => false]);
|
||||
$finished = pcsAbo(pcsUser(), attributes: ['status' => 5, 'active' => false]);
|
||||
|
||||
expect(pcsRender($cancelled))->not->toContain('abo-pause-cancel')
|
||||
->and(pcsRender($finished))->not->toContain('abo-pause-cancel');
|
||||
});
|
||||
|
||||
it('verweist im Einstellungs-Modal nur noch auf den Abschnitt statt direkt zu kuendigen', function () {
|
||||
$abo = pcsAbo(pcsUser());
|
||||
|
||||
$html = view('admin.abo.modal_abo_update', [
|
||||
'user_abo' => $abo,
|
||||
'data' => ['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'portal'],
|
||||
'route' => '/portal/abo/update',
|
||||
])->render();
|
||||
|
||||
expect($html)
|
||||
->toContain(__('abo.cancel_abo'))
|
||||
->toContain(__('abo.cancel_in_section_hint'))
|
||||
->toContain('abo-pause-cancel')
|
||||
->toContain('name="abo_pause"')
|
||||
->not->toContain('name="abo_cancel"');
|
||||
});
|
||||
|
||||
it('kuendigt als eigenstaendige Aktion: Erfolgsmeldung, kein Liefertermin-Hinweis, next_date unveraendert', function () {
|
||||
$user = pcsUser();
|
||||
$abo = pcsAbo($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$repository = new AboRepository;
|
||||
$repository->setModel($abo);
|
||||
|
||||
$result = $repository->update([
|
||||
'action' => 'abo_update_settings',
|
||||
'id' => $abo->id,
|
||||
'view' => 'me',
|
||||
'abo_interval' => 5,
|
||||
'abo_is_active' => 1,
|
||||
'abo_cancel' => 'true',
|
||||
'abo_cancel_confirm' => '1',
|
||||
]);
|
||||
|
||||
$abo = $abo->fresh();
|
||||
|
||||
expect($result)->toBeTrue()
|
||||
->and((int) $abo->status)->toBe(4)
|
||||
->and($abo->active)->toBeFalse()
|
||||
->and(Carbon::parse($abo->getRawOriginal('next_date'))->format('Y-m-d'))->toBe('2026-10-05')
|
||||
->and(session('alert-success'))->toBe(__('abo.cancel_success'))
|
||||
->and(session('alert-warning'))->toBeNull()
|
||||
->and(session('alert-error'))->toBeNull();
|
||||
});
|
||||
|
||||
it('lehnt die Kuendigung vor der Mindestlaufzeit ab, ohne den Liefertag anzufassen', function () {
|
||||
$user = pcsUser();
|
||||
$abo = pcsAbo($user, paidDeliveries: 2);
|
||||
$this->actingAs($user);
|
||||
|
||||
$repository = new AboRepository;
|
||||
$repository->setModel($abo);
|
||||
|
||||
$result = $repository->update([
|
||||
'action' => 'abo_update_settings',
|
||||
'id' => $abo->id,
|
||||
'view' => 'me',
|
||||
'abo_interval' => 5,
|
||||
'abo_cancel' => 'true',
|
||||
]);
|
||||
|
||||
$abo = $abo->fresh();
|
||||
|
||||
expect($result)->toBeFalse()
|
||||
->and((int) $abo->status)->toBe(2)
|
||||
->and($abo->active)->toBeTrue()
|
||||
->and(session('alert-error'))->toBe(__('abo.info_min_duration_orders_left', ['count' => 4]))
|
||||
->and(session('alert-success'))->toBeNull()
|
||||
->and(session('alert-warning'))->toBeNull();
|
||||
});
|
||||
|
||||
it('rendert den Abschnitt mit einer abweichenden Mindestlaufzeit aus dem Setting', function () {
|
||||
Setting::setContentBySlug('abo-min-duration', 3, 'int');
|
||||
$abo = pcsAbo(pcsUser(), paidDeliveries: 3);
|
||||
|
||||
$html = pcsRender($abo);
|
||||
|
||||
expect($html)
|
||||
->toContain(__('abo.pause_cancel_progress', ['count' => 3, 'min' => 3]))
|
||||
->toContain(__('abo.cancel_consequence_restart', ['min' => 3]))
|
||||
->toContain('id="modal-abo-cancel"');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue