0 verlaengert den Vorlauf. */ private const DEFAULT_LOCK_DAYS_CHANGE = 0; /** Gleiche Regel fuer Pausieren/Kuendigen (Rueckfrage R2, 14.09.: ja). */ private const DEFAULT_LOCK_DAYS_PAUSE_CANCEL = 0; /** * Sperrfrist (Kalendertage) vor der Ausführung, ab der das Bearbeiten des Abos * (Intervall/Produkte) blockiert wird. Setting `abo-lock-days-change`: * nicht gepflegt/leer => Default, `0` => ausdruecklich keine Vorlaufsperre. */ public static function getLockDaysChange(): int { return self::lockDaysFromSetting('abo-lock-days-change', self::DEFAULT_LOCK_DAYS_CHANGE); } /** * Sperrfrist (Kalendertage) vor der Ausführung, ab der das Pausieren/Kündigen * des Abos blockiert wird. Setting `abo-lock-days-pause-cancel`, Semantik * wie {@see getLockDaysChange()}. */ public static function getLockDaysPauseCancel(): int { return self::lockDaysFromSetting('abo-lock-days-pause-cancel', self::DEFAULT_LOCK_DAYS_PAUSE_CANCEL); } private static function lockDaysFromSetting(string $slug, int $default): int { $raw = Setting::getContentBySlug($slug); if ($raw === false || $raw === null || $raw === '') { return $default; } return max(0, (int) $raw); } 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; // Laufende Pause: ihr Ende ist der naechste Liefertermin — wandert // der, wandert das Pausenende mit (Audit 18.09., F3). if ($this->model->getRawOriginal('paused_until')) { $this->model->paused_until = $nextDate->copy()->startOfDay(); } $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 vor der Ausfuehrung (Default: ab 0:00 Uhr des Ausfuehrungstags) if (! $isAdminUpdate && AboHelper::isWithinLock($this->model, self::getLockDaysPauseCancel())) { \Session()->flash('alert-error', AboHelper::lockMessage('error_cancel_locked', $this->model, self::getLockDaysPauseCancel())); return; } // Die Folgen der Kuendigung (Ticket A4) muessen bestaetigt sein — auch // serverseitig, nicht nur ueber den deaktivierten Button (Audit 18.09.). if (! $isAdminUpdate && ! filter_var($data['abo_cancel_confirm'] ?? false, FILTER_VALIDATE_BOOLEAN)) { \Session()->flash('alert-error', __('abo.cancel_confirm_required')); 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 vor der Ausfuehrung: keine Deaktivierung mehr if (! $isAdminUpdate && $this->model->active && ! $active && AboHelper::isWithinLock($this->model, self::getLockDaysPauseCancel())) { \Session()->flash('alert-error', AboHelper::lockMessage('error_pause_locked', $this->model, 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) { if (AboHelper::isWithinLock($this->model, self::getLockDaysPauseCancel())) { \Session()->flash('alert-error', AboHelper::lockMessage('error_pause_locked', $this->model, 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; // 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); \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']) || ! UserAbo::isAllowedDeliveryDay($data['abo_interval'])) { \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 vor der anstehenden Ausfuehrung (Default: ab 0:00 Uhr des Ausfuehrungstags) if (AboHelper::isWithinLock($this->model, self::getLockDaysChange())) { \Session()->flash('alert-error', AboHelper::lockMessage('error_change_locked', $this->model, self::getLockDaysChange())); return false; } // Ticket A1 / Rueckfrage R1: Die Aenderung gilt fuer den laufenden Zyklus // (Monat des naechsten Termins) und nur "nach vorn". Ist der gewuenschte Tag in // diesem Monat schon vorbei, wird abgelehnt — sonst wuerde die Lieferung dieses // Monats entfallen oder in den Folgemonat rutschen. Der Monat verschiebt sich nie. $newNextDate = $this->calculateNewNextDate((int) $data['abo_interval']); $daysUntilNewDate = (int) Carbon::today()->diffInDays($newNextDate, false); if ($daysUntilNewDate <= 0) { \Session()->flash('alert-error', __('abo.error_abo_interval_passed', [ 'day' => (int) $data['abo_interval'], 'next' => $this->model->getRawOriginal('next_date') ? Carbon::parse($this->model->getRawOriginal('next_date'))->format(\Util::formatDateDB()) : '-', ])); return false; } if ($daysUntilNewDate <= self::getLockDaysChange()) { \Session()->flash('alert-error', __('abo.error_abo_interval_too_soon', ['days' => $daysUntilNewDate, 'lock' => self::getLockDaysChange()])); return false; } } return true; } /** * Neuer Liefertermin = gewaehlter Tag im Referenzmonat (geklammert auf das * Monatsende). Referenzmonat ist der Monat des aktuellen next_date, wenn der * schon im Folgemonat liegt (die Lieferung dieses Monats ist gelaufen), sonst * der laufende Monat. Ob der Termin noch in der Zukunft liegt, prueft * {@see validate()} — der Monat wird hier nie verschoben. */ private function calculateNewNextDate(int $aboInterval): Carbon { $referenceMonth = Carbon::today()->startOfMonth(); if ($this->model->next_date) { $currentNextDate = Carbon::parse($this->model->getRawOriginal('next_date') ?: $this->model->next_date); if ($currentNextDate->format('Y-m') > $referenceMonth->format('Y-m')) { $referenceMonth = $currentNextDate->copy()->startOfMonth(); } } return AboHelper::dateForDayInMonth($referenceMonth, $aboInterval); } private function calculateAdminNextDate(array $data): Carbon { return AboHelper::dateForDayInMonth( Carbon::createFromFormat('Y-m-d', $data['abo_next_month'].'-01'), (int) $data['abo_interval'] ); } /** * @return array */ 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'; } }