mivita/app/Repositories/AboRepository.php
Kevin e8057f9f7b 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>
2026-09-16 10:14:51 +00:00

378 lines
16 KiB
PHP

<?php
namespace App\Repositories;
use App\Models\Setting;
use App\Models\UserAbo;
use App\Models\UserAboStatusHistory;
use App\Services\AboHelper;
use Carbon\Carbon;
class AboRepository extends BaseRepository
{
private const DEFAULT_LOCK_DAYS_CHANGE = 10;
private const DEFAULT_LOCK_DAYS_PAUSE_CANCEL = 3;
/**
* Sperrfrist (Kalendertage) vor der Ausführung, ab der das Bearbeiten des Abos
* (Intervall/Produkte) blockiert wird. Konfigurierbar über das Setting
* `abo-lock-days-change`; Fallback auf den Default, wenn leer/0.
*/
public static function getLockDaysChange(): int
{
$days = (int) Setting::getContentBySlug('abo-lock-days-change');
return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_CHANGE;
}
/**
* Sperrfrist (Kalendertage) vor der Ausführung, ab der das Pausieren/Kündigen
* des Abos blockiert wird. Konfigurierbar über das Setting
* `abo-lock-days-pause-cancel`; Fallback auf den Default, wenn leer/0.
*/
public static function getLockDaysPauseCancel(): int
{
$days = (int) Setting::getContentBySlug('abo-lock-days-pause-cancel');
return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_PAUSE_CANCEL;
}
public function __construct()
{
// $this->model = $model;
}
public function setModel(UserAbo $model)
{
$this->model = $model;
}
public function update($data)
{
// Der Kanal wird für den gesamten Schreibvorgang gesetzt, damit auch die
// zentral am Model protokollierten Zustandswechsel wissen, aus welchem
// Formular sie stammen ({@see UserAbo::logStatusTransition()}).
return UserAbo::withStatusLogView($data['view'] ?? null, fn () => $this->updateWithinStatusLogView($data));
}
private function updateWithinStatusLogView($data)
{
if (isset($data['action'])) {
if ($data['action'] === 'abo_update_settings') {
if ($this->validate($data)) {
// Pause ist eine eigenständige Aktion: sie verschiebt nur next_date
// und darf nicht von der anschließenden Liefertag-Neuberechnung
// überschrieben werden.
if (isset($data['abo_pause']) && $data['abo_pause'] == 'true') {
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)
? $this->calculateAdminNextDate($data)
: $this->calculateNewNextDate((int) $data['abo_interval']);
$this->model->next_date = $nextDate;
$this->model->save();
$daysUntilNext = AboHelper::calendarDaysUntil(now(), $nextDate);
\Session()->flash('alert-warning', __('abo.warning_next_date_info', ['days' => $daysUntilNext, 'date' => $nextDate->format(\Util::formatDateDB())]));
return true;
}
return false;
}
}
return false;
}
public function create($data) {}
/**
* Prüft die Regeln für Kündigung, Deaktivierung und Reaktivierung und setzt den
* neuen Zustand. Der Protokolleintrag entsteht nicht hier, sondern zentral in
* {@see UserAbo::logStatusTransition()} — sonst fehlt er in jedem Schreibpfad,
* der nicht über dieses Formular läuft. Nur die Pause protokolliert selbst, weil
* sie den Aktiv-Zustand nicht ändert und die Datumsverschiebung mitführt.
*/
private function updateStatus($data)
{
$isAdminUpdate = $this->isAdminUpdate($data);
// Handle cancellation
if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') {
// Mindestlaufzeit serverseitig durchsetzen — die UI blendet den Button nur aus
if (! $isAdminUpdate && ! AboHelper::canCancelAbo($this->model, $data['view'] ?? 'user')) {
\Session()->flash('alert-error', __('abo.info_min_duration_orders_left', [
'count' => max(0, (int) AboHelper::getAboMinDuration() - $this->model->getCountPaidOrders()),
]));
return;
}
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden
if (! $isAdminUpdate && $this->model->next_date) {
$daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false);
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
\Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
return;
}
}
// Status 4 = abo_cancel (storniert/gekündigt)
$this->model->status = 4;
$this->model->active = false;
$this->model->cancel_date = now();
$this->model->save();
return;
}
// A01: Im Selbstbedienungspfad ist der Aktiv-Zustand kein Eingabefeld. Das
// Formular spiegelt ihn nur in einem versteckten Feld zurück; würde er von
// dort übernommen, liesse sich die Mindestlaufzeit durch dauerhafte
// Deaktivierung umgehen und der Ein-Zyklus-Pausenpfad samt seiner Sperren
// ganz überspringen. Zustandswechsel laufen hier ausschliesslich über die
// dafür zugelassenen Aktionen Pause und Kündigung.
$active = $isAdminUpdate
? (bool) ($data['abo_is_active'] ?? false)
: (bool) $this->model->active;
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden
if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) {
$daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false);
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
\Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
return;
}
}
// if status is active and active is false, set status to inactive
if ($this->model->active && ! $active) {
if ($this->model->status == 2) { // okay
$this->model->status = 6; // inactive
$this->model->active = false;
$this->model->save();
}
}
if (! $this->model->active && $active) {
if ($this->model->status == 6) { // inactive
$this->model->status = 2; // okay
$this->model->active = true;
$this->model->save();
}
}
$this->model->active = $active;
}
/**
* Pausiert das Abo um genau einen Ausführungs-Zyklus: next_date wandert auf den
* gleichen Liefertag des Folgemonats, das Abo bleibt aktiv (Status 2).
* Regeln (außer Admin): Sperrfrist vor der Ausführung, Mindestlaufzeit und
* max. 1 Pause pro X Monate ({@see AboHelper::canPauseAbo()}).
*/
private function pauseOneMonth(array $data): bool
{
// Zeilen-Lock + frisches Modell: zwei parallele Pause-Requests dürfen next_date
// nicht beide auf Basis des alten Standes verschieben (Doppel-Pause).
return \DB::transaction(function () use ($data): bool {
$this->model = UserAbo::whereKey($this->model->id)->lockForUpdate()->firstOrFail();
return $this->pauseOneMonthLocked($data);
});
}
private function pauseOneMonthLocked(array $data): bool
{
$isAdminUpdate = $this->isAdminUpdate($data);
if (! $this->model->active || (int) $this->model->status !== 2 || ! $this->model->getRawOriginal('next_date')) {
\Session()->flash('alert-error', __('abo.error_pause_not_active'));
return false;
}
if (! $isAdminUpdate) {
$daysUntil = (int) now()->diffInDays(Carbon::parse($this->model->getRawOriginal('next_date')), false);
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
\Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
return false;
}
if (! AboHelper::canCancelAbo($this->model, $data['view'] ?? 'user')) {
\Session()->flash('alert-error', __('abo.info_min_duration_orders_left', ['count' => (int) AboHelper::getAboMinDuration() - $this->model->getCountPaidOrders()]));
return false;
}
if (($nextPossible = AboHelper::nextPossiblePauseDate($this->model)) !== null) {
\Session()->flash('alert-error', __('abo.error_pause_interval', ['months' => AboHelper::getPauseIntervalMonths(), 'date' => $nextPossible->format(\Util::formatDateDB())]));
return false;
}
}
$previousNextDate = Carbon::parse($this->model->getRawOriginal('next_date'));
$newNextDate = AboHelper::setNextDate($previousNextDate, (int) $this->model->abo_interval);
$this->model->next_date = $newNextDate;
$this->model->last_pause_date = now();
$this->model->pause_count = (int) $this->model->pause_count + 1;
$this->model->save();
$this->logStatusHistory(UserAboStatusHistory::ACTION_PAUSED, $data, $previousNextDate, $newNextDate);
\Session()->flash('alert-success', __('abo.pause_success', ['date' => $newNextDate->format(\Util::formatDateDB())]));
return true;
}
private function logStatusHistory(string $action, array $data, ?Carbon $previousNextDate = null, ?Carbon $newNextDate = null): void
{
UserAboStatusHistory::create([
'user_abo_id' => $this->model->id,
'action' => $action,
'view' => $data['view'] ?? null,
'actor_user_id' => \Auth::id(),
'previous_next_date' => $previousNextDate,
'new_next_date' => $newNextDate,
]);
}
private function validate($data)
{
if ($data['view'] !== 'admin' && $data['view'] !== 'portal') {
if ($this->model->is_for === 'me' && $this->model->user_id !== \Auth::user()->id) {
\Session()->flash('alert-error', 'Unauthorized action. User ID does not match.');
return false;
}
if ($this->model->is_for === 'ot' && $this->model->member_id !== \Auth::user()->id) {
\Session()->flash('alert-error', 'Unauthorized action. User ID does not match.');
return false;
}
if ($data['view'] === 'me' && $this->model->is_for !== 'me') {
\Session()->flash('alert-error', 'Unauthorized action. Is not for me');
return false;
}
if ($data['view'] === 'ot' && $this->model->is_for !== 'ot') {
\Session()->flash('alert-error', 'Unauthorized action. Is not your customer');
return false;
}
}
if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], UserAbo::$aboDeliveryDays, true)) {
\Session()->flash('alert-error', __('abo.error_abo_interval'));
return false;
}
if ($this->isAdminUpdate($data)) {
if (! isset($data['abo_next_month']) || ! in_array($data['abo_next_month'], $this->getAdminExecutionMonths(), true)) {
\Session()->flash('alert-error', __('abo.error_next_date'));
return false;
}
if ($this->calculateAdminNextDate($data)->startOfDay()->lt(now()->startOfDay())) {
\Session()->flash('alert-error', __('abo.error_next_date'));
return false;
}
return true;
}
// Die Change-Sperre (Liefertag/Intervall) greift NUR, wenn der Liefertag tatsächlich
// geändert wird. Reines Pausieren/Kündigen (Intervall unverändert) unterliegt
// ausschließlich der eigenen Sperrfrist in updateStatus() + der Mindestlaufzeit.
$intervalChanged = (int) $data['abo_interval'] !== (int) $this->model->abo_interval;
if ($intervalChanged) {
// Sperre: X Tage vor nächster Ausführung kein Liefertag-Wechsel mehr (Pakete werden vorgepackt)
if ($this->model->next_date) {
$daysUntilExecution = (int) now()->diffInDays(Carbon::parse($this->model->next_date), false);
if ($daysUntilExecution >= 0 && $daysUntilExecution < self::getLockDaysChange()) {
\Session()->flash('alert-error', __('abo.error_change_locked', ['days' => $daysUntilExecution, 'lock' => self::getLockDaysChange()]));
return false;
}
}
// Prüfung: Das neue berechnete Ausführungsdatum muss mindestens getLockDaysChange() Tage entfernt sein.
// Falls next_date bereits in einem zukünftigen Monat liegt, wird das neue Datum in diesem Monat berechnet.
$newNextDate = $this->calculateNewNextDate($data['abo_interval']);
$daysUntilNewDate = (int) now()->diffInDays($newNextDate, false);
if ($daysUntilNewDate < self::getLockDaysChange()) {
\Session()->flash('alert-error', __('abo.error_abo_interval_too_soon', ['days' => $daysUntilNewDate, 'lock' => self::getLockDaysChange()]));
return false;
}
}
return true;
}
/**
* Berechnet das neue Ausführungsdatum unter Berücksichtigung des aktuellen next_date.
* Falls next_date bereits in einem zukünftigen Monat liegt, wird der Monatsanfang
* dieses Monats als Referenz verwendet, sodass der neue Tag im selben Monat landet.
*/
private function calculateNewNextDate(int $aboInterval): Carbon
{
$referenceDate = now();
if ($this->model->next_date) {
$currentNextDate = Carbon::parse($this->model->next_date);
if ($currentNextDate->format('Y-m') > now()->format('Y-m')) {
$referenceDate = $currentNextDate->startOfMonth();
}
}
return AboHelper::setNextDate($referenceDate, $aboInterval);
}
private function calculateAdminNextDate(array $data): Carbon
{
return Carbon::createFromFormat('Y-m-d', $data['abo_next_month'].'-01')
->startOfMonth()
->addDays(((int) $data['abo_interval']) - 1);
}
/**
* @return array<int, string>
*/
private function getAdminExecutionMonths(): array
{
return collect(range(0, 3))
->map(fn (int $offset): string => now()->copy()->startOfMonth()->addMonths($offset)->format('Y-m'))
->all();
}
private function isAdminUpdate(array $data): bool
{
return ($data['view'] ?? null) === 'admin';
}
}