diff --git a/app/Http/Controllers/Admin/AboController.php b/app/Http/Controllers/Admin/AboController.php index 00b5e1c0..062e4cff 100644 --- a/app/Http/Controllers/Admin/AboController.php +++ b/app/Http/Controllers/Admin/AboController.php @@ -27,7 +27,7 @@ class AboController extends Controller public function index() { if (Request::get('reset') === 'filter') { - 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) { + foreach (['filter_member_id', 'filter_status', 'filter_state', 'filter_active', 'filter_is_for', 'filter_payment', 'filter_start_month', 'filter_start_year', 'filter_next_from', 'filter_next_to'] as $attr) { set_user_attr($attr, null); } @@ -41,7 +41,6 @@ class AboController extends Controller 'filter_statuses' => $this->getStatusFilterOptions(), 'filter_months' => $this->getMonthFilterOptions(), 'filter_years' => $this->getStartYearFilterOptions(), - 'filter_next_days' => UserAbo::$aboDeliveryDays, ]; return view('admin.abo.index', $data); @@ -226,9 +225,15 @@ class AboController extends Controller $query->whereYear('start_date', (int) Request::get('filter_start_year')); } - set_user_attr('filter_next_day', Request::get('filter_next_day')); - if (Request::get('filter_next_day') != '') { - $query->where('abo_interval', '=', (int) Request::get('filter_next_day')); + // Ticket A1: Liefertage sind frei (1-31), daher Datumsbereich statt Tages-Dropdown. + set_user_attr('filter_next_from', Request::get('filter_next_from')); + if (Request::get('filter_next_from') != '') { + $query->whereDate('next_date', '>=', Request::get('filter_next_from')); + } + + set_user_attr('filter_next_to', Request::get('filter_next_to')); + if (Request::get('filter_next_to') != '') { + $query->whereDate('next_date', '<=', Request::get('filter_next_to')); } return \DataTables::eloquent($query) diff --git a/app/Http/Controllers/Portal/AboController.php b/app/Http/Controllers/Portal/AboController.php index 606575d0..bad829ed 100644 --- a/app/Http/Controllers/Portal/AboController.php +++ b/app/Http/Controllers/Portal/AboController.php @@ -23,7 +23,6 @@ use App\Services\Shop; use App\Services\UserService; use App\Services\Util; use Auth; -use Carbon\Carbon; use Request; use Yard; @@ -128,10 +127,7 @@ class AboController extends Controller AboOrderCart::initYard($user_abo); if ($changeLocked) { - $message = __('abo.error_change_locked', [ - 'days' => (int) now()->diffInDays(Carbon::parse($user_abo->next_date), false), - 'lock' => AboRepository::getLockDaysChange(), - ]); + $message = AboHelper::lockMessage('error_change_locked', $user_abo, AboRepository::getLockDaysChange()); } // addProduct @@ -745,7 +741,7 @@ class AboController extends Controller if (! Request::boolean('abo_order_info_checkbox')) { $data['error'] = __('abo.abo_order_info_checkbox_required'); $data['step'] = 4; - } elseif (! in_array((int) Request::input('abo_interval'), UserAbo::$aboDeliveryDays, true)) { + } elseif (! UserAbo::isAllowedDeliveryDay(Request::input('abo_interval'))) { $data['error'] = __('abo.error_abo_interval'); $data['step'] = 4; } elseif (! $this->preCheckCheckout()) { diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index 120c21bf..fef7032c 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -25,7 +25,6 @@ use App\Services\OrderPaymentService; use App\Services\Payment\PaymentProviderManager; use App\Services\Shop; use App\User; -use Carbon\Carbon; use Request; use Yard; @@ -207,10 +206,7 @@ class AboController extends Controller AboOrderCart::initYard($user_abo); if ($changeLocked) { - $message = __('abo.error_change_locked', [ - 'days' => (int) now()->diffInDays(Carbon::parse($user_abo->next_date), false), - 'lock' => AboRepository::getLockDaysChange(), - ]); + $message = AboHelper::lockMessage('error_change_locked', $user_abo, AboRepository::getLockDaysChange()); } // addProduct if (! $changeLocked && $data['action'] === 'addProduct') { diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index f7b323b8..50c6f320 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -10,6 +10,7 @@ use App\Enums\PaymentProvider; use App\Services\Abo\AboStatusExplainer; use App\Services\Abo\AboStatusInfo; use App\Services\Abo\TeamAboOverviewService; +use App\Services\AboHelper; use App\Services\AboPaymentMethodService; use App\Services\Backoffice\BackofficeDashboardService; use App\Services\Util; @@ -328,7 +329,26 @@ class UserAbo extends Model 'card_update_pending_at', ]; - public static $aboDeliveryDays = [5, 10, 20, 25]; + /** + * Ticket A1: jeder Monatstag 1-31 ist als Liefertag waehlbar. In kuerzeren + * Monaten liefert ein Abo mit Tag 29-31 am letzten Tag des Monats + * ({@see AboHelper::dateForDayInMonth()}); der Folgemonat wird + * immer wieder aus `abo_interval` gerechnet, nie aus dem geklammerten Termin. + */ + public const MAX_DELIVERY_DAY = 31; + + /** + * @return int[] + */ + public static function allowedDeliveryDays(): array + { + return range(1, self::MAX_DELIVERY_DAY); + } + + public static function isAllowedDeliveryDay(mixed $day): bool + { + return is_numeric($day) && (int) $day == $day && in_array((int) $day, self::allowedDeliveryDays(), true); + } /** `status_reason` (Ticket A3): Zahlungsfehler, Pause/Kuendigung/Deaktivierung je Akteur. */ public const REASON_PAYMENT_FAILED = 'payment_failed'; diff --git a/app/Repositories/AboRepository.php b/app/Repositories/AboRepository.php index 3bc4d76e..fe431717 100644 --- a/app/Repositories/AboRepository.php +++ b/app/Repositories/AboRepository.php @@ -10,32 +10,45 @@ use Carbon\Carbon; class AboRepository extends BaseRepository { - private const DEFAULT_LOCK_DAYS_CHANGE = 10; + /** + * Ticket A2: Liefertag und Produkte sind bis 23:59 Uhr des Vortags aenderbar, + * am Ausfuehrungstag ab 0:00 Uhr fixiert. Das entspricht einer Sperrfrist + * von 0 Tagen; ein gepflegtes Setting > 0 verlaengert den Vorlauf. + */ + private const DEFAULT_LOCK_DAYS_CHANGE = 0; - private const DEFAULT_LOCK_DAYS_PAUSE_CANCEL = 3; + /** 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. Konfigurierbar über das Setting - * `abo-lock-days-change`; Fallback auf den Default, wenn leer/0. + * (Intervall/Produkte) blockiert wird. Setting `abo-lock-days-change`: + * nicht gepflegt/leer => Default, `0` => ausdruecklich keine Vorlaufsperre. */ public static function getLockDaysChange(): int { - $days = (int) Setting::getContentBySlug('abo-lock-days-change'); - - return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_CHANGE; + 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. Konfigurierbar über das Setting - * `abo-lock-days-pause-cancel`; Fallback auf den Default, wenn leer/0. + * des Abos blockiert wird. Setting `abo-lock-days-pause-cancel`, Semantik + * wie {@see getLockDaysChange()}. */ public static function getLockDaysPauseCancel(): int { - $days = (int) Setting::getContentBySlug('abo-lock-days-pause-cancel'); + return self::lockDaysFromSetting('abo-lock-days-pause-cancel', self::DEFAULT_LOCK_DAYS_PAUSE_CANCEL); + } - return $days > 0 ? $days : 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() @@ -128,14 +141,11 @@ class AboRepository extends BaseRepository 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()])); + // 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; - } + return; } // Status 4 = abo_cancel (storniert/gekündigt) @@ -156,14 +166,11 @@ class AboRepository extends BaseRepository $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()])); + // 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; - } + return; } // if status is active and active is false, set status to inactive if ($this->model->active && ! $active) { @@ -211,9 +218,8 @@ class AboRepository extends BaseRepository } 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()])); + if (AboHelper::isWithinLock($this->model, self::getLockDaysPauseCancel())) { + \Session()->flash('alert-error', AboHelper::lockMessage('error_pause_locked', $this->model, self::getLockDaysPauseCancel())); return false; } @@ -285,7 +291,7 @@ class AboRepository extends BaseRepository return false; } } - if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], UserAbo::$aboDeliveryDays, true)) { + if (! isset($data['abo_interval']) || ! UserAbo::isAllowedDeliveryDay($data['abo_interval'])) { \Session()->flash('alert-error', __('abo.error_abo_interval')); return false; @@ -313,21 +319,30 @@ class AboRepository extends BaseRepository $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()])); + // 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; - } + 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()) { + // 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; @@ -338,30 +353,33 @@ class AboRepository extends BaseRepository } /** - * 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. + * 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 { - $referenceDate = now(); + $referenceMonth = Carbon::today()->startOfMonth(); if ($this->model->next_date) { - $currentNextDate = Carbon::parse($this->model->next_date); + $currentNextDate = Carbon::parse($this->model->getRawOriginal('next_date') ?: $this->model->next_date); - if ($currentNextDate->format('Y-m') > now()->format('Y-m')) { - $referenceDate = $currentNextDate->startOfMonth(); + if ($currentNextDate->format('Y-m') > $referenceMonth->format('Y-m')) { + $referenceMonth = $currentNextDate->copy()->startOfMonth(); } } - return AboHelper::setNextDate($referenceDate, $aboInterval); + return AboHelper::dateForDayInMonth($referenceMonth, $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 AboHelper::dateForDayInMonth( + Carbon::createFromFormat('Y-m-d', $data['abo_next_month'].'-01'), + (int) $data['abo_interval'] + ); } /** diff --git a/app/Services/Abo/PaylineForecastService.php b/app/Services/Abo/PaylineForecastService.php index 5ed42d90..24c5b86a 100644 --- a/app/Services/Abo/PaylineForecastService.php +++ b/app/Services/Abo/PaylineForecastService.php @@ -38,6 +38,13 @@ class PaylineForecastService private const CACHE_HOURS = 6; + /** + * Stichtage der Prognose-Kacheln. Seit Ticket A1 sind Liefertage frei (1-31); + * die vier klassischen Termine bleiben als Orientierung, dazu kommt das + * Monatsende, damit Abos nach dem 25. nicht aus der letzten Kachel fallen. + */ + public const FORECAST_DAYS = [5, 10, 20, 25]; + public function __construct( private BackofficeDashboardService $dashboardService, private LiveCommissionService $commissionService, @@ -293,8 +300,8 @@ class PaylineForecastService $pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints(); } - $deliveryDays = UserAbo::$aboDeliveryDays; - $lastDeliveryDay = (int) max($deliveryDays); + $lastDeliveryDay = (int) $today->daysInMonth; + $deliveryDays = array_values(array_unique([...self::FORECAST_DAYS, $lastDeliveryDay])); $days = []; foreach ($deliveryDays as $deliveryDay) { @@ -326,6 +333,7 @@ class PaylineForecastService 'upcoming' => $upcoming, 'total' => $base + $upcoming, 'is_past' => $deliveryDay < (int) $today->day, + 'is_month_end' => $deliveryDay === $lastDeliveryDay, ]; } diff --git a/app/Services/Abo/TeamAboOverviewService.php b/app/Services/Abo/TeamAboOverviewService.php index 1e7c4552..590b2ac7 100644 --- a/app/Services/Abo/TeamAboOverviewService.php +++ b/app/Services/Abo/TeamAboOverviewService.php @@ -435,12 +435,11 @@ class TeamAboOverviewService return 0.0; } - $lastDeliveryDay = (int) max(UserAbo::$aboDeliveryDays); - $windowEnd = Carbon::create($year, $month, 1)->addDays($lastDeliveryDay - 1)->endOfDay(); + // Ticket A1: Liefertage sind frei (1-31), das Fenster reicht bis Monatsende. + // A13: Am Liefertag selbst ist das Fenster noch offen, solange der + // Ausführungsjob nicht gelaufen ist. + $windowEnd = Carbon::create($year, $month, 1)->endOfMonth(); - // A13: Am 25. selbst ist das Fenster noch offen, solange der - // Ausführungsjob nicht gelaufen ist — erst ab dem 26. gibt es nichts mehr - // zu prognostizieren. if ($windowEnd->lt($today->copy()->startOfDay())) { return 0.0; } diff --git a/app/Services/AboHelper.php b/app/Services/AboHelper.php index 8785a61e..e36a1499 100644 --- a/app/Services/AboHelper.php +++ b/app/Services/AboHelper.php @@ -203,13 +203,52 @@ class AboHelper */ public static function isAboChangeLocked(UserAbo $userAbo, string $view = 'user'): bool { - if ($view === 'admin' || ! $userAbo->next_date) { + if ($view === 'admin') { return false; } - $daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false); + return self::isWithinLock($userAbo, AboRepository::getLockDaysChange()); + } - return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysChange(); + /** + * Kalendertage von heute bis zum naechsten Liefertermin (negativ, wenn der + * Termin liegen geblieben ist); null ohne Termin. + */ + public static function daysUntilNextDate(UserAbo $userAbo): ?int + { + $raw = $userAbo->getRawOriginal('next_date') ?: $userAbo->next_date; + + if (! $raw) { + return null; + } + + return (int) Carbon::today()->diffInDays(Carbon::parse($raw)->startOfDay(), false); + } + + /** + * Sperre (Ticket A2): fixiert ab 0:00 Uhr des Tages `next_date - lockDays`. + * Mit lockDays = 0 ist das genau der Ausfuehrungstag — bis 23:59 Uhr des + * Vortags bleibt alles aenderbar. Ein liegen gebliebener Termin (negativ) + * sperrt nicht. + */ + public static function isWithinLock(UserAbo $userAbo, int $lockDays): bool + { + $daysUntil = self::daysUntilNextDate($userAbo); + + return $daysUntil !== null && $daysUntil >= 0 && $daysUntil <= $lockDays; + } + + /** + * Sperrtext fuer Aenderung / Pause / Kuendigung: bei Sperre 0 der Text + * "bis zum Vortag" (`abo._zero`), sonst der Text mit Vorlauf + * (`abo.`, Platzhalter :lock, :days, :date). + */ + public static function lockMessage(string $key, UserAbo $userAbo, int $lockDays): string + { + $daysUntil = self::daysUntilNextDate($userAbo) ?? 0; + $date = $userAbo->getRawOriginal('next_date') ? Carbon::parse($userAbo->getRawOriginal('next_date'))->format(\Util::formatDateDB()) : '-'; + + return __('abo.'.$key.($lockDays <= 0 ? '_zero' : ''), ['lock' => $lockDays, 'days' => max(0, $daysUntil), 'date' => $date]); } /** @@ -222,13 +261,11 @@ class AboHelper */ public static function isPauseCancelLocked(UserAbo $userAbo, string $view = 'user'): bool { - if ($view === 'admin' || ! $userAbo->next_date) { + if ($view === 'admin') { return false; } - $daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false); - - return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysPauseCancel(); + return self::isWithinLock($userAbo, AboRepository::getLockDaysPauseCancel()); } public static function canEditAbo($user_abo, $view = 'user') @@ -285,12 +322,33 @@ class AboHelper return ''; } + /** + * Liefertermin fuer den gewuenschten Monatstag im Monat von $month, in + * kuerzeren Monaten der letzte Tag (31 im Februar => 28./29.). Einzige + * Stelle, die aus Tag + Monat ein Datum macht (Ticket A1). + */ + public static function dateForDayInMonth(Carbon|string $month, int $day): Carbon + { + $first = Carbon::parse($month)->startOfMonth()->startOfDay(); + + return $first->day(max(1, min($day, $first->daysInMonth))); + } + + /** + * Naechster Liefertermin nach $date fuer den Monatstag $abo_interval: im + * Monat von $date, wenn dieser Termin noch nach $date liegt, sonst im + * Folgemonat. Der Folgemonat wird aus dem Monatstag gerechnet, nicht aus dem + * (ggf. geklammerten) Vortermin — ein 31er-Abo bleibt nach dem Februar nicht + * auf dem 28. + */ public static function setNextDate($date, $abo_interval) { - $nextDate = Carbon::parse($date)->firstOfMonth(); - $nextDate->addDays($abo_interval - 1); + $reference = Carbon::parse($date); + $nextDate = self::dateForDayInMonth($reference, (int) $abo_interval); - return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1); + return $nextDate->gt($reference) + ? $nextDate + : self::dateForDayInMonth($reference->copy()->startOfMonth()->addMonth(), (int) $abo_interval); } /** @@ -374,14 +432,13 @@ class AboHelper */ private static function computeFirstAboCandidateWithoutMinDays(Carbon $reference, int $aboDayOfMonth): Carbon { - $nextDate = $reference->copy()->firstOfMonth()->addMonth(1); - $nextDate->addDays($aboDayOfMonth - 1); + $nextDate = self::dateForDayInMonth($reference->copy()->startOfMonth()->addMonth(), $aboDayOfMonth); if (! $nextDate->gt($reference)) { - $nextDate->addMonth(1); + $nextDate = self::advanceAboCandidateOneMonth($nextDate, $aboDayOfMonth); } - return $nextDate->copy()->startOfDay(); + return $nextDate; } /** @@ -389,11 +446,7 @@ class AboHelper */ private static function advanceAboCandidateOneMonth(Carbon $candidate, int $aboDayOfMonth): Carbon { - $next = $candidate->copy()->addMonthNoOverflow(); - $dim = $next->daysInMonth; - $day = min($aboDayOfMonth, $dim); - - return $next->day($day)->startOfDay(); + return self::dateForDayInMonth($candidate->copy()->startOfMonth()->addMonth(), $aboDayOfMonth); } public static function createNewAbo(ShoppingPayment $shopping_payment) diff --git a/app/Services/HTMLHelper.php b/app/Services/HTMLHelper.php index 7c12fef0..25bd35d2 100644 --- a/app/Services/HTMLHelper.php +++ b/app/Services/HTMLHelper.php @@ -113,7 +113,7 @@ class HTMLHelper public static function getAboDeliveryOptions($default = 5) { - $values = UserAbo::$aboDeliveryDays; + $values = UserAbo::allowedDeliveryDays(); $ret = ''; foreach ($values as $value) { $attr = ($value == $default) ? 'selected="selected"' : ''; @@ -128,7 +128,14 @@ class HTMLHelper public static function getAboStrLang($num) { - return $num.'. '.__('abo.of_month'); + $label = $num.'. '.__('abo.of_month'); + + // Tage 29-31 gibt es nicht in jedem Monat: dann liefert das Abo am letzten Tag. + if ((int) $num >= 29) { + $label .= ' '.__('abo.of_month_short_hint'); + } + + return $label; } public static function getAboFirstExecutionDate($date, $interval) diff --git a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md index 0aa02946..4d27c746 100644 --- a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md +++ b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md @@ -1,6 +1,6 @@ # Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026 -**Stand:** 16.09.2026 · **Status:** C1/C2 live seit 14.09., A4 und A3 umgesetzt (committet, Deploy offen); als Nächstes A1+A2 +**Stand:** 16.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett **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) @@ -10,8 +10,8 @@ | # | Thema | Quelle | Aufwand | Risiko | Blockiert durch | |---|---|---|---|---|---| -| **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 | +| **A1** | Abo-Ausführungstag frei wählbar (1–31, Monatsende-Regel) – **umgesetzt 16.09., Deploy offen** (Lager/Versand: Freigabe Kevin 16.09.) | Ticket #1, Protokoll | 3–4 PT | mittel | – | +| **A2** | Änderungssperre erst ab 0:00 Uhr am Ausführungstag – **umgesetzt 16.09., Deploy offen (Settings auf 0!)** | Ticket #1 | 1 PT | niedrig | zusammen mit A1 | | **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 | @@ -37,7 +37,7 @@ ## 1. Abo-Paket -### A1 – Ausführungstag frei wählbar (1–31) +### A1 – Ausführungstag frei wählbar (1–31) — umgesetzt 16.09., Deploy offen **Ziel (Ticket #1):** Jeder Tag 1–31 wählbar; bei 29/30/31 in kürzeren Monaten der letzte Monatstag. Genau eine Ausführung pro Monat. Änderung nur „nach vorne“ (10. → 24. ja, 10. → 5. nein, wenn der 5. schon vorbei ist). Wird am Ausführungstag 0:00 Uhr fixiert (→ A2). @@ -72,7 +72,7 @@ - **Rückfrage R1:** Wenn der gewünschte Tag im laufenden Monat schon vorbei ist – Fehlermeldung, oder Änderung automatisch für den Folgemonat übernehmen (dann würde diese Monatslieferung noch am alten Tag laufen)? - Bestehende Abos ändern sich nicht; keine Datenmigration. -### A2 – Änderungssperre erst am Ausführungstag ab 0:00 Uhr +### A2 – Änderungssperre erst am Ausführungstag ab 0:00 Uhr — umgesetzt 16.09., Deploy offen **Ziel (Ticket #1):** Tag und Produkte bis 23:59 Uhr des Vortags änderbar, dann fixiert. Die Sperre bleibt im Code (Setting), Default effektiv 0. Einmalprodukte behalten ihr eigenes Fenster (4 Tage vorher). @@ -95,6 +95,24 @@ **Risiko:** niedrig. Zeitliche Lücke 0:00–04:00 Uhr ist durch die neue Bedingung geschlossen. +**Umsetzung A1+A2 16.09.2026 (ein Commit auf `payment/phase-r-payone-refactoring`):** + +- **Liefertage:** `UserAbo::$aboDeliveryDays` ersetzt durch `UserAbo::allowedDeliveryDays()` (1–31) und `isAllowedDeliveryDay()`; alle sieben Verwendungen umgestellt. Select mit 31 Einträgen, Tage 29–31 mit Zusatz „(in kürzeren Monaten der letzte Tag)“. Kein Schema-Change, bestehende Werte 5/10/20/25 unverändert. +- **Datum:** eine Stelle `AboHelper::dateForDayInMonth($monat, $tag)` (Klammerung auf Monatsende). `setNextDate()`, Erst-Ausführung (`getFirstAboDate`), Admin-Termin (`calculateAdminNextDate`) und Pause rechnen darüber. Der Folgemonat kommt immer aus `abo_interval`: 31. Januar → 28. Februar → 31. März → 30. April (Test). Nebenbei behoben: Tag 1 mit Referenz Monatsanfang sprang bisher einen Monat weiter. +- **Änderungsregel (R1):** `calculateNewNextDate()` = gewählter Tag im Referenzmonat (Monat des nächsten Termins, wenn der schon im Folgemonat liegt, sonst laufender Monat), nie ein anderer Monat. Liegt der Tag nicht nach heute → Fehler `error_abo_interval_passed` („Der 5. ist in diesem Monat schon vorbei. Die nächste Lieferung bleibt am …“). Die 10-Tage-Prüfung entfällt; bei gepflegter Sperrfrist > 0 gilt weiterhin `error_abo_interval_too_soon`. +- **Sperre (A2, R2):** Defaults `abo-lock-days-change` und `abo-lock-days-pause-cancel` = 0; Setting leer → Default, Setting `0` → 0 (vorher fiel 0 auf 10/3 zurück). Bedingung jetzt `Kalendertage bis next_date <= lock`, zentral in `AboHelper::isWithinLock()` — mit 0 ist genau der Ausführungstag ab 0:00 Uhr gesperrt, der Vortag bis 23:59 Uhr frei. Gilt für Liefertag, Produkte (AJAX-Guards), Pause und Kündigung; Admin unverändert frei. Texte über `AboHelper::lockMessage()`: bei 0 die `_zero`-Varianten („…wird heute ausgeführt. Nach der Lieferung kannst du…“), sonst die Varianten mit `:lock` Tagen. +- **Admin-Filter:** „nächste Ausführung“ als Datumsbereich (von/bis auf `next_date`) statt Tages-Dropdown. +- **Prognosen:** Kacheln 5./10./20./25. bleiben, dazu die Kachel **Monatsende** (`is_month_end`, Label „Prognose zum Monatsende (30.09.2026)“) in Dashboard und Kundenabo-Liste; Nachzügler-Buchungen hängen jetzt an dieser Kachel. Team-Abos: „bis Monatsende erwartet“ statt „bis zum 25.“ (`TeamAboOverviewService`, Fenster bis Monatsende). +- Sprachdateien de/en/es/fr (abo.php, home.php). Tests: neu `AboDeliveryDayTest` (7 Tests: Tage 1–31, Klammerung, 31er-Kette, Erstausführung, „nur nach vorn“, Sperre am Ausführungstag, Admin-Klammerung); angepasst `AboLockDaysSettingTest`, `AboRepositoryDateChangeTest`, `AboPauseTest`, `AboPauseCancelSectionTest`, `PaylineForecastServiceTest`. 335 Tests der Abo-/Prognose-Suite grün (bis auf den vorbestehenden Fehlschlag in `AboPendingInitialPaymentTest`). +- **Deploy:** Dateien hochladen, `php artisan view:clear`, dann die beiden Settings auf 0 setzen — sonst bleiben die auf Produktion gepflegten Werte (10 bzw. 3) wirksam: + + ```bash + php artisan tinker --execute="App\\Models\\Setting::setContentBySlug('abo-lock-days-change', 0, 'int'); App\\Models\\Setting::setContentBySlug('abo-lock-days-pause-cancel', 0, 'int');" + ``` + + Keine Migration, keine Datenänderung. Danach prüfen: Abo-Einstellungen zeigen 31 Tage; ein Abo mit Termin morgen lässt sich noch ändern; heute fälliges Abo ist gesperrt. +- **Nicht enthalten:** die Mindestfrist 10 Tage bis zur Erst-Ausführung bleibt (`MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION`); das Cron-Fenster 04:00 Uhr bleibt; Einmalprodukte behalten ihr eigenes 4-Tage-Fenster. + ### 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. diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index c9c2ed42..2a2bec91 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -32,7 +32,7 @@ return [ 'abo_is_active' => 'Abo ist aktiv', 'abo_copy_active' => 'Wenn das Abonnement nicht aktiv ist, erfolgt keine automatische Ausführung.', 'abo_copy_next_date' => 'Der nächste Ausführungstermin kann frühesten auf den Folgetag festgelegt werden.', - 'abo_copy_abo_interval' => 'Die Anpassung des Abonnement-Liefertags wirkt sich auf den kommenden Ausführungstermin aus, wenn das Abonnement aktiv ist.', + 'abo_copy_abo_interval' => 'Der Liefertag gilt ab der kommenden Lieferung und lässt sich im laufenden Zyklus nur nach vorn verschieben. Bis 23:59 Uhr des Vortags änderbar, am Liefertag fixiert.', 'admin_abo_copy_next_date' => 'Admins können den nächsten Ausführungstermin direkt über Monat und Liefertag festlegen.', 'error_abo_interval' => 'Das Abo Interval nicht korrekt', 'error_abo_interval_in_the_past' => 'Das Abo wurde diesen Monat noch nicht ausgeführt. Eine Änderung auf einen vergangenen Tag würde den aktuellen Monat überspringen.', @@ -40,9 +40,9 @@ return [ 'warning_next_date_soon_select' => 'Hinweis: Die nächste Abo-Ausführung ist bereits in :placeholder_days Tagen (:placeholder_date).', 'warning_next_date_info' => 'Die nächste Abo-Ausführung ist in :days Tagen am :date.', 'info_next_execution_select' => 'Nächste Ausführung: in :placeholder_days Tagen am :placeholder_date.', - 'error_change_locked' => 'Änderungen sind nicht mehr möglich. Die nächste Ausführung ist in :days Tagen. Änderungen müssen mindestens :lock Tage vorher erfolgen.', + 'error_change_locked' => 'Änderungen sind nicht mehr möglich: Die Lieferung am :date ist fixiert. Änderungen müssen mindestens :lock Tage vorher erfolgen.', 'error_abo_interval_too_soon' => 'Der gewählte Liefertag liegt nur :days Tage entfernt. Bitte wähle einen Liefertag, der mindestens :lock Tage in der Zukunft liegt.', - 'error_cancel_locked' => 'Eine Kündigung ist nicht mehr möglich. Die nächste Ausführung ist in :days Tagen. Kündigungen müssen mindestens :lock Tage vorher erfolgen.', + 'error_cancel_locked' => 'Eine Kündigung ist nicht mehr möglich: Die Lieferung am :date ist fixiert. Kündigungen müssen mindestens :lock Tage vorher erfolgen.', 'abo_copy_pause' => 'Verschiebt die nächste Ausführung um einen Monat nach hinten. Maximal 1x alle :months Monate möglich.', 'payline_abo_turnover' => 'Abo-Umsatz in der Payline', 'payline_abo_turnover_hint' => 'Soll = Abo-Punkte pro Zyklus aller aktiven Abos deiner Payline (Berater- und Kundenabos). Ist = im laufenden Monat bereits abgerechnete Abo-Punkte der Payline.', @@ -52,7 +52,7 @@ return [ 'payline_abo_counts' => ':team_abos Teamabos + :customer_abos Kundenabos in der Payline = :total Abos gesamt', 'abo_order_short' => '(Abo)', 'points_order_legend' => 'Bestellungen: blau = eigene Bestellung · grau = Kundenbestellung · türkis mit „(Abo)" = Abo-Lieferung.', - 'customer_abo_forecast_title' => 'Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25.', + 'customer_abo_forecast_title' => 'Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25. / Monatsende', 'customer_abo_forecast_hint' => 'Taggenau: je Termin die bis dahin aus deinen Kundenabos abgerechneten Punkte plus die Lieferungen, die bis dahin noch anstehen. Ohne dein eigenes Abo und ohne Team.', 'customer_abo_forecast_split' => ':billed abgerechnet + :upcoming offen', 'customer_abo_points_recurring' => 'Abo-Punkte', @@ -69,15 +69,15 @@ return [ 'payline_none' => 'Noch keine Payline vorhanden (kein Provisionslevel erreicht).', 'abo_points_per_cycle' => 'Punkte pro Lieferung (Bestand)', 'abo_points_billed' => 'abgerechnet (:month)', - 'abo_points_expected' => 'bis zum :day. erwartet', - 'abo_points_expected_hint' => 'Bereits abgerechnete Abo-Punkte plus die Punkte aller Abos dieser Ebene, deren Lieferung bis zum :day. des Monats noch ansteht.', + 'abo_points_expected' => 'bis Monatsende erwartet', + 'abo_points_expected_hint' => 'Bereits abgerechnete Abo-Punkte plus die Punkte aller Abos dieser Ebene, deren Lieferung in diesem Monat noch ansteht.', 'pause_abo' => '1 Monat pausieren', 'confirm_pause' => 'Möchtest du das Abo wirklich um einen Monat pausieren? Die nächste Lieferung verschiebt sich auf den Folgemonat.', 'pause_success' => 'Dein Abo wurde um einen Monat pausiert. Die nächste Ausführung ist am :date.', 'error_pause_interval' => 'Das Abo kann maximal 1x alle :months Monate pausiert werden. Die nächste Pause ist ab dem :date möglich.', 'error_pause_not_active' => 'Nur aktive Abos können pausiert werden.', 'info_pause_available_from' => 'Eine Pause ist maximal 1x alle :months Monate möglich — wieder ab dem :date.', - 'error_pause_locked' => 'Das Abo kann nicht mehr pausiert werden. Die nächste Ausführung ist in :days Tagen. Pausieren muss mindestens :lock Tage vorher erfolgen.', + 'error_pause_locked' => 'Das Abo kann nicht mehr pausiert werden: Die Lieferung am :date ist fixiert. Pausieren muss mindestens :lock Tage vorher erfolgen.', 'error_next_date' => 'Das Datum für die nächste Ausführung nicht korrekt', 'checkout_mail_abo_hl' => 'Dein Abo / regelmäßige Lieferung.', 'checkout_mail_abo_start' => 'Dein Abo wurde erfolgreich mit folgenden Einstellungen angelegt:', @@ -335,7 +335,7 @@ return [ '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.', + 'pause_cancel_locked_hint' => 'Die Lieferung am :date ist fixiert (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?', @@ -376,4 +376,14 @@ return [ 'actor_by_admin' => 'vom Admin', 'actor_by_system' => 'vom System', 'filter_state' => 'Zustand', + + // Freier Liefertag 1-31 und Sperre bis zum Vortag (Tickets A1/A2) + 'of_month_short_hint' => '(in kürzeren Monaten der letzte Tag)', + 'error_change_locked_zero' => 'Änderungen sind nicht mehr möglich: Die Lieferung am :date wird heute ausgeführt. Nach der Lieferung kannst du den Liefertag für den nächsten Monat ändern.', + 'error_abo_interval_passed' => 'Der :day. ist in diesem Monat schon vorbei. Die nächste Lieferung bleibt am :next – danach kannst du den Liefertag für den Folgemonat neu festlegen.', + 'error_cancel_locked_zero' => 'Eine Kündigung ist nicht mehr möglich: Die Lieferung am :date wird heute ausgeführt. Nach der Lieferung kannst du kündigen.', + 'error_pause_locked_zero' => 'Das Abo kann nicht mehr pausiert werden: Die Lieferung am :date wird heute ausgeführt. Nach der Lieferung kannst du pausieren.', + 'pause_cancel_locked_hint_zero' => 'Die Lieferung am :date wird heute ausgeführt. Pausieren oder Kündigen ist danach wieder möglich – bis 23:59 Uhr des Vortags einer Lieferung.', + 'filter_next_from' => 'von', + 'filter_next_to' => 'bis', ]; diff --git a/resources/lang/de/home.php b/resources/lang/de/home.php index 122e298e..e8006913 100644 --- a/resources/lang/de/home.php +++ b/resources/lang/de/home.php @@ -50,7 +50,7 @@ return [ 'your_shop' => 'Dein Shop', 'commission_today' => 'Provision (Stand heute)', 'commission_unavailable' => 'Die Provisionsberechnung ist gerade nicht verfuegbar. Der Wert wird nachgetragen, sobald sie wieder laeuft.', - 'payline_forecast_title' => 'Payline-Prognose zum 5. / 10. / 20. / 25.', + 'payline_forecast_title' => 'Payline-Prognose zum 5. / 10. / 20. / 25. / Monatsende', 'payline_forecast_basis' => 'Basis: Paylinepunkte + persönliches Volumen', 'payline_forecast_hint' => 'Basis: aktueller Kundenumsatz + Teamumsatz (Payline). Dazu kommen kumuliert die bis zum jeweiligen Termin noch anstehenden Abo-Punkte deiner Payline (inkl. deiner eigenen Abos und Kundenabos). Prognose: Pausen, Kündigungen oder fehlgeschlagene Zahlungen können den Wert verändern.', 'forecast_day' => 'Prognose zum :date', @@ -106,4 +106,6 @@ return [ 'top_results_unit_points' => 'Punkte', 'top_results_unit_partners' => 'Neupartner', 'top_results_unit_abos' => 'Abos', + + 'forecast_month_end' => 'Prognose zum Monatsende (:date)', ]; diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 5fe86e28..ab3614d5 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -32,7 +32,7 @@ return [ 'abo_is_active' => 'Subscription is active', 'abo_copy_active' => 'If the subscription is not active, it will not be executed automatically', 'abo_copy_next_date' => 'The next execution date can be set to the following day at the earliest', - 'abo_copy_abo_interval' => 'Adjusting the subscription delivery day affects the upcoming execution date when the subscription is active.', + 'abo_copy_abo_interval' => 'The delivery day applies from the upcoming delivery and can only be moved forward within the current cycle. Editable until 11:59 pm the day before, fixed on the delivery day.', 'admin_abo_copy_next_date' => 'Admins can set the next execution date directly using month and delivery day.', 'error_abo_interval' => 'The subscription interval is not correct', 'error_next_date' => 'The date for the next execution is not correct', @@ -166,9 +166,9 @@ return [ 'warning_next_date_soon_select' => 'Note: The next subscription execution is in :placeholder_days days (:placeholder_date).', 'warning_next_date_info' => 'The next subscription execution is in :days days on :date.', 'info_next_execution_select' => 'Next execution: in :placeholder_days days on :placeholder_date.', - 'error_change_locked' => 'Changes are no longer possible. The next execution is in :days days. Changes must be made at least :lock days in advance.', + 'error_change_locked' => 'Changes are no longer possible: the delivery on :date is fixed. Changes must be made at least :lock days in advance.', 'error_abo_interval_too_soon' => 'The selected delivery day is only :days days away. Please choose a delivery day at least :lock days in the future.', - 'error_cancel_locked' => 'Cancellation is no longer possible. The next execution is in :days days. Cancellations must be made at least :lock days in advance.', + 'error_cancel_locked' => 'Cancellation is no longer possible: the delivery on :date is fixed. Cancellations must be made at least :lock days in advance.', 'abo_copy_pause' => 'Moves the next execution back by one month. Possible at most once every :months months.', 'payline_abo_turnover' => 'Subscription turnover in the payline', 'payline_abo_turnover_hint' => 'Target = subscription points per cycle of all active subscriptions in your payline (consultant and customer subscriptions). Actual = subscription points of the payline already billed in the current month.', @@ -178,7 +178,7 @@ return [ 'payline_abo_counts' => ':team_abos team subscriptions + :customer_abos customer subscriptions in the payline = :total subscriptions in total', 'abo_order_short' => '(subscr.)', 'points_order_legend' => 'Orders: blue = your own order · grey = customer order · turquoise with "(subscr.)" = subscription delivery.', - 'customer_abo_forecast_title' => 'Points from my customer subscriptions for the 5th / 10th / 20th / 25th', + 'customer_abo_forecast_title' => 'Points from my customer subscriptions for the 5th / 10th / 20th / 25th / end of month', 'customer_abo_forecast_hint' => 'Day by day: for each date, the points billed from your customer subscriptions up to that day, plus the deliveries still due by then. Without your own subscription and without your team.', 'customer_abo_forecast_split' => ':billed billed + :upcoming due', 'customer_abo_points_recurring' => 'Subscription points', @@ -195,15 +195,15 @@ return [ 'payline_none' => 'No payline yet (no commission level reached).', 'abo_points_per_cycle' => 'Points per delivery (current stock)', 'abo_points_billed' => 'billed (:month)', - 'abo_points_expected' => 'expected by day :day', - 'abo_points_expected_hint' => 'Subscription points already billed plus the points of all subscriptions on this level whose delivery is still due by day :day of the month.', + 'abo_points_expected' => 'expected by end of month', + 'abo_points_expected_hint' => 'Subscription points already billed plus the points of all subscriptions on this level whose delivery is still due this month.', 'pause_abo' => 'Pause for 1 month', 'confirm_pause' => 'Do you really want to pause the subscription for one month? The next delivery will move to the following month.', 'pause_success' => 'Your subscription has been paused for one month. The next execution is on :date.', 'error_pause_interval' => 'The subscription can be paused at most once every :months months. The next pause is possible from :date.', 'error_pause_not_active' => 'Only active subscriptions can be paused.', 'info_pause_available_from' => 'A pause is possible at most once every :months months — available again from :date.', - 'error_pause_locked' => 'The subscription can no longer be paused. The next execution is in :days days. Pausing must be done at least :lock days in advance.', + 'error_pause_locked' => 'The subscription can no longer be paused: the delivery on :date is fixed. Pausing must be done at least :lock days in advance.', 'cancel_abo' => 'Cancel subscription', 'confirm_cancel' => 'Do you really want to cancel the subscription?', 'retry_payment' => 'Retry payment', @@ -334,7 +334,7 @@ return [ '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.', + 'pause_cancel_locked_hint' => 'The delivery on :date is fixed (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?', @@ -374,4 +374,13 @@ return [ 'actor_by_admin' => 'by the admin', 'actor_by_system' => 'by the system', 'filter_state' => 'State', + + 'of_month_short_hint' => '(in shorter months the last day)', + 'error_change_locked_zero' => 'Changes are no longer possible: the delivery on :date is being executed today. After the delivery you can change the delivery day for the next month.', + 'error_abo_interval_passed' => 'The :day. has already passed this month. The next delivery stays on :next – afterwards you can set the delivery day for the following month.', + 'error_cancel_locked_zero' => 'Cancellation is no longer possible: the delivery on :date is being executed today. You can cancel after the delivery.', + 'error_pause_locked_zero' => 'The subscription can no longer be paused: the delivery on :date is being executed today. You can pause after the delivery.', + 'pause_cancel_locked_hint_zero' => 'The delivery on :date is being executed today. Pausing or cancelling is possible again afterwards – until 11:59 pm the day before a delivery.', + 'filter_next_from' => 'from', + 'filter_next_to' => 'to', ]; diff --git a/resources/lang/en/home.php b/resources/lang/en/home.php index 1ca82470..19f5e65f 100644 --- a/resources/lang/en/home.php +++ b/resources/lang/en/home.php @@ -49,7 +49,7 @@ return [ 'your_shop' => 'your shop', 'commission_today' => 'Commission (as of today)', 'commission_unavailable' => 'The commission calculation is currently unavailable. The value will appear once it runs again.', - 'payline_forecast_title' => 'Payline forecast for the 5th / 10th / 20th / 25th', + 'payline_forecast_title' => 'Payline forecast for the 5th / 10th / 20th / 25th / end of month', 'payline_forecast_basis' => 'Basis: payline points + personal volume', 'payline_forecast_hint' => 'Basis: current customer turnover + team turnover (payline). Plus, cumulatively, the subscription points of your payline still due up to the respective date (incl. your own subscriptions and customer subscriptions). Forecast: pauses, cancellations or failed payments can change the value.', 'forecast_day' => 'Forecast for :date', @@ -106,4 +106,6 @@ return [ 'top_results_unit_points' => 'points', 'top_results_unit_partners' => 'new partners', 'top_results_unit_abos' => 'subscriptions', + + 'forecast_month_end' => 'Forecast for end of month (:date)', ]; diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index fab5d260..6e66dd5d 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -32,7 +32,7 @@ return [ 'abo_is_active' => 'La suscripción está activa', 'abo_copy_active' => 'Si la suscripción no está activa, no se ejecutará automáticamente', 'abo_copy_next_date' => 'La siguiente fecha de ejecución puede establecerse como muy pronto al día siguiente', - 'abo_copy_abo_interval' => 'El ajuste del día de entrega de la suscripción afecta a la próxima fecha de ejecución cuando la suscripción está activa.', + 'abo_copy_abo_interval' => 'El día de entrega se aplica a partir de la próxima entrega y solo puede adelantarse dentro del ciclo actual. Modificable hasta las 23:59 del día anterior; el día de entrega queda fijado.', 'admin_abo_copy_next_date' => 'Los administradores pueden establecer la próxima fecha de ejecución directamente mediante mes y día de entrega.', 'error_abo_interval' => 'El intervalo de suscripción no es correcto', 'error_next_date' => 'La fecha de la siguiente ejecución no es correcta', @@ -149,9 +149,9 @@ return [ 'warning_next_date_soon_select' => 'Nota: La próxima ejecución de la suscripción es en :placeholder_days días (:placeholder_date).', 'warning_next_date_info' => 'La próxima ejecución de la suscripción es en :days días el :date.', 'info_next_execution_select' => 'Próxima ejecución: en :placeholder_days días el :placeholder_date.', - 'error_change_locked' => 'Los cambios ya no son posibles. La próxima ejecución es en :days días. Los cambios deben realizarse al menos :lock días antes.', + 'error_change_locked' => 'Ya no es posible realizar cambios: la entrega del :date está fijada. Los cambios deben realizarse al menos :lock días antes.', 'error_abo_interval_too_soon' => 'El día de entrega seleccionado está a solo :days días. Elija un día de entrega con al menos :lock días de anticipación.', - 'error_cancel_locked' => 'La cancelación ya no es posible. La próxima ejecución es en :days días. Las cancelaciones deben realizarse al menos :lock días antes.', + 'error_cancel_locked' => 'La cancelación ya no es posible: la entrega del :date está fijada. Las cancelaciones deben realizarse al menos :lock días antes.', 'abo_copy_pause' => 'Retrasa la próxima ejecución un mes. Posible como máximo 1 vez cada :months meses.', 'payline_abo_turnover' => 'Volumen de suscripciones en la payline', 'payline_abo_turnover_hint' => 'Objetivo = puntos de suscripción por ciclo de todas las suscripciones activas de tu payline (de asesores y clientes). Real = puntos de suscripción de la payline ya facturados en el mes en curso.', @@ -161,7 +161,7 @@ return [ 'payline_abo_counts' => ':team_abos suscripciones de equipo + :customer_abos suscripciones de clientes en la payline = :total suscripciones en total', 'abo_order_short' => '(susc.)', 'points_order_legend' => 'Pedidos: azul = pedido propio · gris = pedido de cliente · turquesa con «(susc.)» = entrega de suscripción.', - 'customer_abo_forecast_title' => 'Puntos de mis suscripciones de clientes para el 5 / 10 / 20 / 25', + 'customer_abo_forecast_title' => 'Puntos de mis suscripciones de clientes para el 5 / 10 / 20 / 25 / fin de mes', 'customer_abo_forecast_hint' => 'Día a día: en cada fecha, los puntos facturados de tus suscripciones de clientes hasta ese día, más las entregas que siguen pendientes. Sin tu propia suscripción y sin el equipo.', 'customer_abo_forecast_split' => ':billed facturados + :upcoming pendientes', 'customer_abo_points_recurring' => 'Puntos de la suscripción', @@ -178,15 +178,15 @@ return [ 'payline_none' => 'Aún no hay payline (no se ha alcanzado ningún nivel de comisión).', 'abo_points_per_cycle' => 'Puntos por entrega (cartera actual)', 'abo_points_billed' => 'facturado (:month)', - 'abo_points_expected' => 'previsto hasta el día :day', - 'abo_points_expected_hint' => 'Puntos de suscripción ya facturados más los puntos de todas las suscripciones de este nivel cuya entrega aún está pendiente hasta el día :day del mes.', + 'abo_points_expected' => 'previsto hasta fin de mes', + 'abo_points_expected_hint' => 'Puntos de suscripción ya facturados más los puntos de todas las suscripciones de este nivel cuya entrega aún está pendiente este mes.', 'pause_abo' => 'Pausar 1 mes', 'confirm_pause' => '¿Realmente quieres pausar la suscripción un mes? La próxima entrega se trasladará al mes siguiente.', 'pause_success' => 'Tu suscripción se ha pausado un mes. La próxima ejecución es el :date.', 'error_pause_interval' => 'La suscripción puede pausarse como máximo 1 vez cada :months meses. La próxima pausa es posible a partir del :date.', 'error_pause_not_active' => 'Solo se pueden pausar suscripciones activas.', 'info_pause_available_from' => 'Una pausa es posible como máximo 1 vez cada :months meses — de nuevo a partir del :date.', - 'error_pause_locked' => 'La suscripción ya no puede pausarse. La próxima ejecución es en :days días. La pausa debe realizarse al menos :lock días antes.', + 'error_pause_locked' => 'La suscripción ya no puede pausarse: la entrega del :date está fijada. La pausa debe realizarse al menos :lock días antes.', 'cancel_abo' => 'Cancelar suscripción', 'confirm_cancel' => '¿Realmente desea cancelar la suscripción?', 'retry_payment' => 'Reintentar pago', @@ -334,7 +334,7 @@ return [ '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.', + 'pause_cancel_locked_hint' => 'La entrega del :date está fijada (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?', @@ -374,4 +374,13 @@ return [ 'actor_by_admin' => 'por el administrador', 'actor_by_system' => 'por el sistema', 'filter_state' => 'Estado actual', + + 'of_month_short_hint' => '(en meses más cortos, el último día)', + 'error_change_locked_zero' => 'Ya no es posible realizar cambios: la entrega del :date se ejecuta hoy. Después de la entrega podrás cambiar el día de entrega para el mes siguiente.', + 'error_abo_interval_passed' => 'El día :day ya ha pasado este mes. La próxima entrega se mantiene el :next; después podrás fijar el día de entrega para el mes siguiente.', + 'error_cancel_locked_zero' => 'La cancelación ya no es posible: la entrega del :date se ejecuta hoy. Podrás cancelar después de la entrega.', + 'error_pause_locked_zero' => 'La suscripción ya no puede pausarse: la entrega del :date se ejecuta hoy. Podrás pausar después de la entrega.', + 'pause_cancel_locked_hint_zero' => 'La entrega del :date se ejecuta hoy. Pausar o cancelar volverá a ser posible después, hasta las 23:59 del día anterior a una entrega.', + 'filter_next_from' => 'desde', + 'filter_next_to' => 'hasta', ]; diff --git a/resources/lang/es/home.php b/resources/lang/es/home.php index 7d0163c7..06aa67f1 100644 --- a/resources/lang/es/home.php +++ b/resources/lang/es/home.php @@ -49,7 +49,7 @@ return [ 'your_shop' => 'tu tienda', 'commission_today' => 'Comisión (a día de hoy)', 'commission_unavailable' => 'El calculo de comisiones no esta disponible en este momento. El valor aparecera cuando vuelva a ejecutarse.', - 'payline_forecast_title' => 'Pronóstico de payline para el 5 / 10 / 20 / 25', + 'payline_forecast_title' => 'Pronóstico de payline para el 5 / 10 / 20 / 25 / fin de mes', 'payline_forecast_basis' => 'Base: puntos de payline + volumen personal', 'payline_forecast_hint' => 'Base: volumen de clientes actual + volumen del equipo (payline). Más, de forma acumulada, los puntos de suscripción de tu payline pendientes hasta la fecha correspondiente (incl. tus propias suscripciones y las de tus clientes). Pronóstico: pausas, cancelaciones o pagos fallidos pueden cambiar el valor.', 'forecast_day' => 'Pronóstico para el :date', @@ -106,4 +106,6 @@ return [ 'top_results_unit_points' => 'puntos', 'top_results_unit_partners' => 'nuevos socios', 'top_results_unit_abos' => 'suscripciones', + + 'forecast_month_end' => 'Pronóstico a fin de mes (:date)', ]; diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php index 92fd9cda..a37bf342 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -32,7 +32,7 @@ return [ 'abo_is_active' => 'L’abonnement est actif', 'abo_copy_active' => 'Si l’abonnement n’est pas actif, aucune exécution automatique n’a lieu.', 'abo_copy_next_date' => 'Le prochain jour d’exécution peut être fixé au plus tôt au lendemain.', - 'abo_copy_abo_interval' => 'L’adaptation du jour de livraison de l’abonnement impacte la prochaine date d’exécution si l’abonnement est actif.', + 'abo_copy_abo_interval' => 'Le jour de livraison s’applique dès la prochaine livraison et ne peut être que repoussé dans le cycle en cours. Modifiable jusqu’à 23 h 59 la veille, figé le jour de la livraison.', 'admin_abo_copy_next_date' => 'Les admins peuvent définir directement la prochaine date d’exécution via le mois et le jour de livraison.', 'error_abo_interval' => 'L’intervalle d’abonnement est incorrect', 'error_abo_interval_in_the_past' => 'L’abonnement n’a pas encore été exécuté ce mois-ci. Un changement vers un jour passé ferait sauter le mois en cours.', @@ -40,9 +40,9 @@ return [ 'warning_next_date_soon_select' => 'Remarque : la prochaine exécution de l’abonnement est déjà dans :placeholder_days jours (:placeholder_date).', 'warning_next_date_info' => 'La prochaine exécution de l’abonnement est dans :days jours, le :date.', 'info_next_execution_select' => 'Prochaine exécution : dans :placeholder_days jours, le :placeholder_date.', - 'error_change_locked' => 'Les modifications ne sont plus possibles. La prochaine exécution est dans :days jours. Les modifications doivent être effectuées au moins :lock jours avant.', + 'error_change_locked' => 'Les modifications ne sont plus possibles : la livraison du :date est figée. Les modifications doivent être effectuées au moins :lock jours avant.', 'error_abo_interval_too_soon' => 'Le jour de livraison choisi n’est qu’à :days jours. Veuillez choisir un jour de livraison situé au moins :lock jours dans le futur.', - 'error_cancel_locked' => 'Une résiliation n’est plus possible. La prochaine exécution est dans :days jours. Les résiliations doivent être effectuées au moins :lock jours avant.', + 'error_cancel_locked' => 'Une résiliation n’est plus possible : la livraison du :date est figée. Les résiliations doivent être effectuées au moins :lock jours avant.', 'abo_copy_pause' => 'Reporte la prochaine exécution d’un mois. Possible au maximum 1 fois tous les :months mois.', 'payline_abo_turnover' => 'Chiffre d’affaires des abonnements dans la payline', 'payline_abo_turnover_hint' => 'Objectif = points d’abonnement par cycle de tous les abonnements actifs de ta payline (abonnements conseillers et clients). Réel = points d’abonnement de la payline déjà facturés au cours du mois.', @@ -52,7 +52,7 @@ return [ 'payline_abo_counts' => ':team_abos abonnements équipe + :customer_abos abonnements clients dans la payline = :total abonnements au total', 'abo_order_short' => '(abo)', 'points_order_legend' => 'Commandes : bleu = commande personnelle · gris = commande client · turquoise avec « (abo) » = livraison d’abonnement.', - 'customer_abo_forecast_title' => 'Points de mes abonnements clients pour le 5 / 10 / 20 / 25', + 'customer_abo_forecast_title' => 'Points de mes abonnements clients pour le 5 / 10 / 20 / 25 / fin de mois', 'customer_abo_forecast_hint' => 'Jour par jour : à chaque date, les points facturés sur tes abonnements clients jusqu’à ce jour, plus les livraisons encore à venir. Sans ton propre abonnement et sans l’équipe.', 'customer_abo_forecast_split' => ':billed facturés + :upcoming à venir', 'customer_abo_points_recurring' => 'Points de l’abonnement', @@ -69,15 +69,15 @@ return [ 'payline_none' => 'Pas encore de payline (aucun niveau de commission atteint).', 'abo_points_per_cycle' => 'Points par livraison (portefeuille actuel)', 'abo_points_billed' => 'facturé (:month)', - 'abo_points_expected' => 'prévu jusqu’au :day', - 'abo_points_expected_hint' => 'Points d’abonnement déjà facturés plus les points de tous les abonnements de ce niveau dont la livraison est encore à venir d’ici le :day du mois.', + 'abo_points_expected' => 'prévu jusqu’à la fin du mois', + 'abo_points_expected_hint' => 'Points d’abonnement déjà facturés plus les points de tous les abonnements de ce niveau dont la livraison est encore à venir ce mois-ci.', 'pause_abo' => 'Suspendre 1 mois', 'confirm_pause' => 'Veux-tu vraiment suspendre l’abonnement pendant un mois ? La prochaine livraison sera reportée au mois suivant.', 'pause_success' => 'Ton abonnement a été suspendu pour un mois. La prochaine exécution aura lieu le :date.', 'error_pause_interval' => 'L’abonnement ne peut être suspendu qu’une fois tous les :months mois. La prochaine pause est possible à partir du :date.', 'error_pause_not_active' => 'Seuls les abonnements actifs peuvent être suspendus.', 'info_pause_available_from' => 'Une pause est possible au maximum 1 fois tous les :months mois — de nouveau à partir du :date.', - 'error_pause_locked' => 'L’abonnement ne peut plus être mis en pause. La prochaine exécution est dans :days jours. La pause doit être effectuée au moins :lock jours avant.', + 'error_pause_locked' => 'L’abonnement ne peut plus être mis en pause : la livraison du :date est figée. La pause doit être effectuée au moins :lock jours avant.', 'error_next_date' => 'La date de la prochaine exécution est incorrecte', 'checkout_mail_abo_hl' => 'Votre abonnement / livraison régulière.', 'checkout_mail_abo_start' => 'Votre abonnement a été créé avec succès avec les paramètres suivants :', @@ -334,7 +334,7 @@ return [ '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.', + 'pause_cancel_locked_hint' => 'La livraison du :date est figée (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 ?', @@ -374,4 +374,13 @@ return [ 'actor_by_admin' => 'par l’administrateur', 'actor_by_system' => 'par le système', 'filter_state' => 'État', + + 'of_month_short_hint' => '(dans les mois plus courts, le dernier jour)', + 'error_change_locked_zero' => 'Les modifications ne sont plus possibles : la livraison du :date est exécutée aujourd’hui. Après la livraison, tu pourras changer le jour de livraison pour le mois suivant.', + 'error_abo_interval_passed' => 'Le :day est déjà passé ce mois-ci. La prochaine livraison reste le :next ; ensuite tu pourras fixer le jour de livraison pour le mois suivant.', + 'error_cancel_locked_zero' => 'Une résiliation n’est plus possible : la livraison du :date est exécutée aujourd’hui. Tu pourras résilier après la livraison.', + 'error_pause_locked_zero' => 'L’abonnement ne peut plus être mis en pause : la livraison du :date est exécutée aujourd’hui. Tu pourras suspendre après la livraison.', + 'pause_cancel_locked_hint_zero' => 'La livraison du :date est exécutée aujourd’hui. Suspendre ou résilier sera de nouveau possible ensuite – jusqu’à 23 h 59 la veille d’une livraison.', + 'filter_next_from' => 'du', + 'filter_next_to' => 'au', ]; diff --git a/resources/lang/fr/home.php b/resources/lang/fr/home.php index ca9a3e78..9174c27e 100644 --- a/resources/lang/fr/home.php +++ b/resources/lang/fr/home.php @@ -50,7 +50,7 @@ return [ 'your_shop' => 'Votre boutique', 'commission_today' => 'Commission (à ce jour)', 'commission_unavailable' => 'Le calcul des commissions n\'est pas disponible actuellement. La valeur apparaitra des qu\'il fonctionnera de nouveau.', - 'payline_forecast_title' => 'Prévision payline pour le 5 / 10 / 20 / 25', + 'payline_forecast_title' => 'Prévision payline pour le 5 / 10 / 20 / 25 / fin de mois', 'payline_forecast_basis' => 'Base : points payline + volume personnel', 'payline_forecast_hint' => 'Base : chiffre d’affaires clients actuel + chiffre d’affaires équipe (payline). S’y ajoutent, de manière cumulée, les points d’abonnement de ta payline encore à venir jusqu’à la date concernée (y compris tes propres abonnements et abonnements clients). Prévision : pauses, résiliations ou paiements échoués peuvent modifier la valeur.', 'forecast_day' => 'Prévision pour le :date', @@ -106,4 +106,6 @@ return [ 'top_results_unit_points' => 'points', 'top_results_unit_partners' => 'nouveaux partenaires', 'top_results_unit_abos' => 'abonnements', + + 'forecast_month_end' => 'Prévision à la fin du mois (:date)', ]; diff --git a/resources/views/admin/abo/_pause_cancel.blade.php b/resources/views/admin/abo/_pause_cancel.blade.php index d3c76bc7..5d6d889f 100644 --- a/resources/views/admin/abo/_pause_cancel.blade.php +++ b/resources/views/admin/abo/_pause_cancel.blade.php @@ -43,10 +43,7 @@ @elseif ($pcLocked && $pcNextDate)
- {{ __('abo.pause_cancel_locked_hint', [ - 'date' => $pcNextDate->format(Util::formatDateDB()), - 'lock' => App\Repositories\AboRepository::getLockDaysPauseCancel(), - ]) }} + {{ App\Services\AboHelper::lockMessage('pause_cancel_locked_hint', $user_abo, App\Repositories\AboRepository::getLockDaysPauseCancel()) }}
@else
diff --git a/resources/views/admin/abo/index.blade.php b/resources/views/admin/abo/index.blade.php index 045ab4bf..ca12e7dc 100644 --- a/resources/views/admin/abo/index.blade.php +++ b/resources/views/admin/abo/index.blade.php @@ -69,14 +69,12 @@
-
- - +
+ +
+ + +
@@ -136,7 +134,8 @@ d.filter_payment = $('select[name=filter_payment]').val(); d.filter_start_month = $('select[name=filter_start_month]').val(); d.filter_start_year = $('select[name=filter_start_year]').val(); - d.filter_next_day = $('select[name=filter_next_day]').val(); + d.filter_next_from = $('input[name=filter_next_from]').val(); + d.filter_next_to = $('input[name=filter_next_to]').val(); } }, "order": [[0, "desc" ]], @@ -164,7 +163,7 @@ "url": "/js/datatables-{{ \App::getLocale() }}.json" } }); - $('#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(){ + $('#filter_start_month, #filter_start_year, #filter_next_from, #filter_next_to, #filter_status, #filter_state, #filter_active, #filter_is_for, #filter_payment, #filter_member_id').on('change', function(){ oTable.draw(); }); }); diff --git a/resources/views/admin/abo/modal_abo_update.blade.php b/resources/views/admin/abo/modal_abo_update.blade.php index c3dc36d6..5667432b 100644 --- a/resources/views/admin/abo/modal_abo_update.blade.php +++ b/resources/views/admin/abo/modal_abo_update.blade.php @@ -22,10 +22,7 @@ @if($aboChangeLocked)
- {{ __('abo.error_change_locked', [ - 'days' => (int) now()->diffInDays(\Carbon\Carbon::parse($user_abo->next_date), false), - 'lock' => App\Repositories\AboRepository::getLockDaysChange(), - ]) }} + {{ App\Services\AboHelper::lockMessage('error_change_locked', $user_abo, App\Repositories\AboRepository::getLockDaysChange()) }}
@endif diff --git a/resources/views/dashboard/_statistics.blade.php b/resources/views/dashboard/_statistics.blade.php index 23e91e1a..8f42d32d 100644 --- a/resources/views/dashboard/_statistics.blade.php +++ b/resources/views/dashboard/_statistics.blade.php @@ -268,11 +268,11 @@
{{ __('home.payline_forecast_basis') }}
@foreach ($s['payline_forecast']['days'] as $forecastDay) -
+
- {{ __('home.forecast_day', ['date' => $forecastDay['date']]) }} + {{ ! empty($forecastDay['is_month_end']) ? __('home.forecast_month_end', ['date' => $forecastDay['date']]) : __('home.forecast_day', ['date' => $forecastDay['date']]) }}
{{ $fmt($forecastDay['total']) }}
@if ($forecastDay['upcoming'] > 0) diff --git a/resources/views/user/abo/_customer_abo_list.blade.php b/resources/views/user/abo/_customer_abo_list.blade.php index 2caa887f..fb9778fd 100644 --- a/resources/views/user/abo/_customer_abo_list.blade.php +++ b/resources/views/user/abo/_customer_abo_list.blade.php @@ -24,11 +24,11 @@
{{ __('abo.customer_abo_forecast_hint') }}
@foreach ($customerAboForecast['days'] as $forecastDay) -
+
- {{ __('home.forecast_day', ['date' => $forecastDay['date']]) }} + {{ ! empty($forecastDay['is_month_end']) ? __('home.forecast_month_end', ['date' => $forecastDay['date']]) : __('home.forecast_day', ['date' => $forecastDay['date']]) }}
{{ \App\Services\Util::formatNumber($forecastDay['total']) }} diff --git a/resources/views/user/team/abos.blade.php b/resources/views/user/team/abos.blade.php index a8618bc8..7c1385bb 100644 --- a/resources/views/user/team/abos.blade.php +++ b/resources/views/user/team/abos.blade.php @@ -95,7 +95,7 @@ {{ __('abo.team_total') }} {{ __('abo.abo_points_per_cycle') }} {{ __('abo.abo_points_billed', ['month' => $currentMonthName]) }} - {{ __('abo.abo_points_expected', ['day' => 25]) }} + {{ __('abo.abo_points_expected') }} diff --git a/tests/Feature/AboDeliveryDayTest.php b/tests/Feature/AboDeliveryDayTest.php new file mode 100644 index 00000000..20c34fed --- /dev/null +++ b/tests/Feature/AboDeliveryDayTest.php @@ -0,0 +1,204 @@ + Carbon::setTestNow()); + +function addUser(): User +{ + return User::forceCreate([ + 'email' => 'add-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'payment_account' => now()->addYear(), + ]); +} + +function addAbo(User $user, int $day, string $nextDate): UserAbo +{ + return UserAbo::create([ + 'user_id' => $user->id, + 'member_id' => $user->id, + 'shopping_user_id' => 0, + 'email' => 'add-abo-'.uniqid('', true).'@example.com', + 'is_for' => 'me', + 'payone_userid' => 0, + 'clearingtype' => 'cc', + 'amount' => 5000, + 'active' => true, + 'status' => 2, + 'abo_interval' => $day, + 'start_date' => '2026-01-05', + 'last_date' => null, + 'next_date' => $nextDate, + ]); +} + +function addChange(UserAbo $abo, int $day, string $view = 'me'): bool +{ + $repository = new AboRepository; + $repository->setModel($abo); + + return $repository->update([ + 'action' => 'abo_update_settings', + 'id' => $abo->id, + 'view' => $view, + 'abo_interval' => $day, + 'abo_is_active' => 1, + ]); +} + +it('erlaubt jeden Monatstag 1-31 und lehnt alles andere ab', function () { + expect(UserAbo::allowedDeliveryDays())->toBe(range(1, 31)) + ->and(UserAbo::isAllowedDeliveryDay(1))->toBeTrue() + ->and(UserAbo::isAllowedDeliveryDay('31'))->toBeTrue() + ->and(UserAbo::isAllowedDeliveryDay(0))->toBeFalse() + ->and(UserAbo::isAllowedDeliveryDay(32))->toBeFalse() + ->and(UserAbo::isAllowedDeliveryDay('5.5'))->toBeFalse() + ->and(UserAbo::isAllowedDeliveryDay(null))->toBeFalse(); +}); + +it('bietet 31 Liefertage an und kennzeichnet 29-31 als letzter Tag in kuerzeren Monaten', function () { + Carbon::setTestNow('2026-09-16'); + + $options = HTMLHelper::getAboDeliveryOptions(31); + + expect(substr_count($options, 'toBe(31) + ->and($options)->toContain('value="1"') + ->and($options)->toContain('value="31" data-days="45" data-date="31.10.2026" selected="selected"') + ->and(HTMLHelper::getAboStrLang(31))->toBe('31. '.__('abo.of_month').' '.__('abo.of_month_short_hint')) + ->and(HTMLHelper::getAboStrLang(28))->toBe('28. '.__('abo.of_month')); +}); + +it('klammert Tag 29-31 auf das Monatsende und rechnet den Folgemonat wieder aus dem Liefertag', function () { + expect(AboHelper::dateForDayInMonth('2026-02-01', 31)->format('Y-m-d'))->toBe('2026-02-28') + ->and(AboHelper::dateForDayInMonth('2028-02-01', 30)->format('Y-m-d'))->toBe('2028-02-29') + ->and(AboHelper::dateForDayInMonth('2026-04-15', 31)->format('Y-m-d'))->toBe('2026-04-30') + ->and(AboHelper::dateForDayInMonth('2026-06-01', 31)->format('Y-m-d'))->toBe('2026-06-30') + ->and(AboHelper::dateForDayInMonth('2026-09-01', 31)->format('Y-m-d'))->toBe('2026-09-30') + ->and(AboHelper::dateForDayInMonth('2026-11-01', 30)->format('Y-m-d'))->toBe('2026-11-30') + ->and(AboHelper::dateForDayInMonth('2026-03-01', 31)->format('Y-m-d'))->toBe('2026-03-31'); + + // Ein 31er-Abo: Januar 31 -> Februar 28 -> Maerz 31 -> April 30 (kein Monat doppelt, keiner uebersprungen). + $next = AboHelper::setNextDate(Carbon::parse('2026-01-31 04:00:00'), 31); + expect($next->format('Y-m-d'))->toBe('2026-02-28'); + $next = AboHelper::setNextDate($next->copy()->setTime(4, 0), 31); + expect($next->format('Y-m-d'))->toBe('2026-03-31'); + $next = AboHelper::setNextDate($next->copy()->setTime(4, 0), 31); + expect($next->format('Y-m-d'))->toBe('2026-04-30'); + + // Tag 1: Ausfuehrung am 1. um 04:00 -> naechster Termin ist der 1. des Folgemonats, nicht uebernaechster. + expect(AboHelper::setNextDate(Carbon::parse('2026-09-01 04:00:00'), 1)->format('Y-m-d'))->toBe('2026-10-01') + ->and(AboHelper::setNextDate(Carbon::parse('2026-09-16 10:00:00'), 20)->format('Y-m-d'))->toBe('2026-09-20') + ->and(AboHelper::setNextDate(Carbon::parse('2026-09-16 10:00:00'), 10)->format('Y-m-d'))->toBe('2026-10-10'); +}); + +it('setzt die Erstausfuehrung fuer Tag 31 im Folgemonat auf dessen letzten Tag', function () { + Carbon::setTestNow('2026-01-15'); + + expect(AboHelper::getFirstAboDate(now(), 31)->format('Y-m-d'))->toBe('2026-02-28') + ->and(AboHelper::getFirstAboDate(now(), 1)->format('Y-m-d'))->toBe('2026-02-01'); + + // Mindestabstand 10 Tage: Bestellung am 25.01. fuer Tag 1 -> nicht 01.02. (7 Tage), sondern 01.03. + Carbon::setTestNow('2026-01-25'); + expect(AboHelper::getFirstAboDate(now(), 1)->format('Y-m-d'))->toBe('2026-03-01'); +}); + +it('verschiebt den Liefertag im laufenden Zyklus nur nach vorn und nie in einen anderen Monat', function () { + Carbon::setTestNow('2026-09-16 10:00:00'); + app('request')->setLaravelSession(app('session.store')); + $user = addUser(); + $this->actingAs($user); + + // 10. -> 24.: Lieferung dieses Monats ist noch offen (next_date 20.09.), neuer Termin 24.09. + $abo = addAbo($user, 20, '2026-09-20'); + expect(addChange($abo, 24))->toBeTrue() + ->and($abo->fresh()->getRawOriginal('next_date'))->toBe('2026-09-24') + ->and($abo->fresh()->abo_interval)->toBe(24); + + // 24. -> 5.: der 5. ist im September vorbei -> Ablehnung, Termin bleibt. + expect(addChange($abo->fresh(), 5))->toBeFalse() + ->and(session('alert-error'))->toBe(__('abo.error_abo_interval_passed', ['day' => 5, 'next' => '24.09.2026'])) + ->and($abo->fresh()->getRawOriginal('next_date'))->toBe('2026-09-24'); + + // Heute selbst (16.) ist ebenfalls vorbei: Aenderung muss nach heute liegen. + expect(addChange($abo->fresh(), 16))->toBeFalse(); + + // 17. (morgen) ist erlaubt. + expect(addChange($abo->fresh(), 17))->toBeTrue() + ->and($abo->fresh()->getRawOriginal('next_date'))->toBe('2026-09-17'); + + // Lieferung schon gelaufen (next_date im Oktober): Referenzmonat Oktober, 5. erlaubt. + $delivered = addAbo($user, 10, '2026-10-10'); + expect(addChange($delivered, 5))->toBeTrue() + ->and($delivered->fresh()->getRawOriginal('next_date'))->toBe('2026-10-05'); + + // Tag 31 im Referenzmonat November -> 30.11. + $november = addAbo($user, 10, '2026-11-10'); + expect(addChange($november, 31))->toBeTrue() + ->and($november->fresh()->getRawOriginal('next_date'))->toBe('2026-11-30') + ->and($november->fresh()->abo_interval)->toBe(31); +}); + +it('sperrt die Liefertag-Aenderung erst am Ausfuehrungstag, am Vortag bis 23:59 Uhr ist sie frei (A2)', function () { + app('request')->setLaravelSession(app('session.store')); + $user = addUser(); + $this->actingAs($user); + $abo = addAbo($user, 20, '2026-09-20'); + + Carbon::setTestNow('2026-09-19 23:59:00'); + expect(addChange($abo, 25))->toBeTrue() + ->and($abo->fresh()->getRawOriginal('next_date'))->toBe('2026-09-25'); + + Carbon::setTestNow('2026-09-25 00:00:00'); + expect(addChange($abo->fresh(), 28))->toBeFalse() + ->and(session('alert-error'))->toBe(__('abo.error_change_locked_zero', ['lock' => 0, 'days' => 0, 'date' => '25.09.2026'])) + ->and($abo->fresh()->getRawOriginal('next_date'))->toBe('2026-09-25'); + + // Mit gepflegter Sperrfrist 3: am 22. (3 Tage vorher) bereits gesperrt, am 21. frei. + Setting::setContentBySlug('abo-lock-days-change', 3, 'int'); + Carbon::setTestNow('2026-09-22 08:00:00'); + expect(addChange($abo->fresh(), 28))->toBeFalse() + ->and(session('alert-error'))->toContain('mindestens 3 Tage'); + + Carbon::setTestNow('2026-09-21 08:00:00'); + // Neuer Termin 28.09. liegt 7 Tage entfernt (> 3) -> erlaubt. + expect(addChange($abo->fresh(), 28))->toBeTrue(); + // Neuer Termin 23.09. laege nur 2 Tage entfernt (<= 3) -> zu frueh. + expect(addChange($abo->fresh(), 23))->toBeFalse() + ->and(session('alert-error'))->toBe(__('abo.error_abo_interval_too_soon', ['days' => 2, 'lock' => 3])); +}); + +it('klammert auch den Admin-Termin und laesst den Admin frei waehlen', function () { + Carbon::setTestNow('2026-09-16 10:00:00'); + app('request')->setLaravelSession(app('session.store')); + $admin = addUser(); + $this->actingAs($admin); + $abo = addAbo($admin, 5, '2026-10-05'); + + $repository = new AboRepository; + $repository->setModel($abo); + + expect($repository->update([ + 'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'admin', + 'abo_interval' => 31, 'abo_next_month' => '2026-11', 'abo_is_active' => 'true', + ]))->toBeTrue() + ->and($abo->fresh()->getRawOriginal('next_date'))->toBe('2026-11-30') + ->and($abo->fresh()->abo_interval)->toBe(31); +}); diff --git a/tests/Feature/AboLockDaysSettingTest.php b/tests/Feature/AboLockDaysSettingTest.php index fcefdf03..28492958 100644 --- a/tests/Feature/AboLockDaysSettingTest.php +++ b/tests/Feature/AboLockDaysSettingTest.php @@ -16,9 +16,9 @@ uses(TestCase::class, RefreshDatabase::class); afterEach(fn () => Carbon::setTestNow()); describe('Abo-Sperrfristen als Setting', function () { - it('nutzt die Defaults, wenn kein Setting gesetzt ist', function () { - expect(AboRepository::getLockDaysChange())->toBe(10) - ->and(AboRepository::getLockDaysPauseCancel())->toBe(3); + it('nutzt die Defaults, wenn kein Setting gesetzt ist (A2: keine Vorlaufsperre)', function () { + expect(AboRepository::getLockDaysChange())->toBe(0) + ->and(AboRepository::getLockDaysPauseCancel())->toBe(0); }); it('liest die konfigurierten Sperrfristen aus den Settings', function () { @@ -29,13 +29,19 @@ describe('Abo-Sperrfristen als Setting', function () { ->and(AboRepository::getLockDaysPauseCancel())->toBe(7); }); - it('fällt bei 0 / leer auf den Default zurück', function () { + it('nimmt 0 als ausdrueckliche Sperrfrist und faellt nur bei leer auf den Default zurueck', function () { Setting::setContentBySlug('abo-lock-days-change', 0, 'int'); + Setting::setContentBySlug('abo-lock-days-pause-cancel', 4, 'int'); - expect(AboRepository::getLockDaysChange())->toBe(10); + expect(AboRepository::getLockDaysChange())->toBe(0) + ->and(AboRepository::getLockDaysPauseCancel())->toBe(4); + + Setting::setContentBySlug('abo-lock-days-pause-cancel', '', 'int'); + + expect(AboRepository::getLockDaysPauseCancel())->toBe(0); }); - it('gibt eine Änderung frei, die bei Default gesperrt wäre, wenn die Sperrfrist reduziert wird', function () { + it('gibt eine Änderung frei, die bei laengerer Sperrfrist gesperrt wäre, wenn die Sperrfrist reduziert wird', function () { Carbon::setTestNow('2026-03-19 00:00:00'); // Liefertag 20, next_date in 6 Tagen → Liefertag-WECHSEL auf 25 testen. @@ -48,7 +54,8 @@ describe('Abo-Sperrfristen als Setting', function () { $repository = new AboRepository; $repository->setModel($abo); - // Mit Default (10) ist der Wechsel gesperrt (6 < 10): + // Mit Sperrfrist 10 ist der Wechsel gesperrt (6 <= 10): + Setting::setContentBySlug('abo-lock-days-change', 10, 'int'); expect($repository->update([ 'action' => 'abo_update_settings', 'id' => $abo->id, @@ -57,7 +64,7 @@ describe('Abo-Sperrfristen als Setting', function () { 'abo_is_active' => 'true', ]))->toBeFalse(); - // Sperrfrist auf 3 Tage reduzieren → Wechsel jetzt erlaubt (6 ≥ 3, neues Datum 2026-03-25): + // Sperrfrist auf 3 Tage reduzieren → Wechsel jetzt erlaubt (6 > 3, neues Datum 2026-03-25): Setting::setContentBySlug('abo-lock-days-change', 3, 'int'); expect($repository->update([ @@ -89,12 +96,15 @@ describe('Abo-Sperrfristen als Setting', function () { 'abo_is_active' => 'true', ]))->toBeFalse() ->and(session('alert-error'))->toContain('mindestens 3 Tage') + ->and(session('alert-error'))->toContain('20.03.2026') ->and(session('alert-error'))->not->toContain('mindestens 10 Tage'); }); it('erlaubt Pausieren im Change-Lock-Fenster, wenn außerhalb der Pause-Sperre', function () { Carbon::setTestNow('2026-03-19 00:00:00'); Setting::setContentBySlug('abo-min-duration', 0, 'int'); + Setting::setContentBySlug('abo-lock-days-change', 10, 'int'); + Setting::setContentBySlug('abo-lock-days-pause-cancel', 3, 'int'); // 5 Tage entfernt: innerhalb Change-Lock (10), außerhalb Pause-Lock (3). $abo = makeLockTestAbo(['abo_interval' => 20, 'next_date' => '2026-03-24', 'active' => true, 'status' => 2]); $this->actingAs($abo->user); @@ -121,24 +131,36 @@ describe('Abo-Sperrfristen als Setting', function () { describe('isAboChangeLocked (UI-Sperre Liefertag)', function () { afterEach(fn () => Carbon::setTestNow()); - it('ist gesperrt im Fenster, frei außerhalb (User-Ansicht)', function () { - Carbon::setTestNow('2026-03-19 00:00:00'); - $abo = makeLockTestAbo(['next_date' => '2026-03-20']); // 1 Tag → < Default 10 + it('sperrt ohne Vorlauf erst am Ausfuehrungstag ab 0:00 Uhr, der Vortag ist frei (A2)', function () { + Carbon::setTestNow('2026-03-19 23:59:00'); + $abo = makeLockTestAbo(['next_date' => '2026-03-20']); - expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeTrue(); + expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse() + ->and(AboHelper::isPauseCancelLocked($abo, 'me'))->toBeFalse(); - $abo->next_date = '2026-04-19'; // weit entfernt - expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse(); + Carbon::setTestNow('2026-03-20 00:00:00'); + $abo = $abo->fresh(); + + expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeTrue() + ->and(AboHelper::isPauseCancelLocked($abo, 'me'))->toBeTrue(); + + // Liegen gebliebener Termin sperrt nicht. + Carbon::setTestNow('2026-03-21 09:00:00'); + expect(AboHelper::isAboChangeLocked($abo->fresh(), 'me'))->toBeFalse(); }); - it('respektiert die konfigurierte Sperrfrist', function () { + it('respektiert die konfigurierte Sperrfrist (gesperrt ab 0:00 Uhr des Tages next_date - lock)', function () { Carbon::setTestNow('2026-03-19 00:00:00'); $abo = makeLockTestAbo(['next_date' => '2026-03-24']); // 5 Tage entfernt - expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeTrue(); // 5 < 10 (Default) + Setting::setContentBySlug('abo-lock-days-change', 10, 'int'); + expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeTrue(); // 5 <= 10 + + Setting::setContentBySlug('abo-lock-days-change', 5, 'int'); + expect(AboHelper::isAboChangeLocked($abo->fresh(), 'me'))->toBeTrue(); // 5 <= 5 Setting::setContentBySlug('abo-lock-days-change', 3, 'int'); - expect(AboHelper::isAboChangeLocked($abo, 'me'))->toBeFalse(); // 5 >= 3 + expect(AboHelper::isAboChangeLocked($abo->fresh(), 'me'))->toBeFalse(); // 5 > 3 }); it('gilt nicht für Admin und nicht ohne next_date', function () { diff --git a/tests/Feature/AboPauseCancelSectionTest.php b/tests/Feature/AboPauseCancelSectionTest.php index 40744f53..77b42f87 100644 --- a/tests/Feature/AboPauseCancelSectionTest.php +++ b/tests/Feature/AboPauseCancelSectionTest.php @@ -111,6 +111,7 @@ it('zeigt vor der Mindestlaufzeit nur den Fortschritt und den Hinweis auf die ve it('zeigt innerhalb der Sperrfrist vor der Ausfuehrung den Hinweis statt der Buttons', function () { // Ausfuehrung in 2 Tagen, Sperre pausieren/kuendigen = 3 Tage + Setting::setContentBySlug('abo-lock-days-pause-cancel', 3, 'int'); $abo = pcsAbo(pcsUser(), nextDate: '2026-09-18'); expect(AboHelper::isPauseCancelLocked($abo, 'me'))->toBeTrue() @@ -119,11 +120,23 @@ it('zeigt innerhalb der Sperrfrist vor der Ausfuehrung den Hinweis statt der But $html = pcsRender($abo); expect($html) - ->toContain(__('abo.pause_cancel_locked_hint', ['date' => '18.09.2026', 'lock' => AboRepository::getLockDaysPauseCancel()])) + ->toContain(__('abo.pause_cancel_locked_hint', ['date' => '18.09.2026', 'lock' => 3, 'days' => 2])) ->not->toContain('id="modal-abo-cancel"') ->not->toContain('name="abo_pause"'); }); +it('zeigt ohne Vorlaufsperre am Ausfuehrungstag den Vortag-Hinweis (A2)', function () { + $abo = pcsAbo(pcsUser(), nextDate: '2026-09-16'); + + $html = pcsRender($abo); + + expect($html) + ->toContain(__('abo.pause_cancel_locked_hint_zero', ['date' => '16.09.2026', 'lock' => 0, 'days' => 0])) + ->not->toContain('id="modal-abo-cancel"'); + + expect(pcsRender(pcsAbo(pcsUser(), nextDate: '2026-09-17')))->toContain('id="modal-abo-cancel"'); +}); + 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']); diff --git a/tests/Feature/AboPauseTest.php b/tests/Feature/AboPauseTest.php index c4ee4084..9b3e1297 100644 --- a/tests/Feature/AboPauseTest.php +++ b/tests/Feature/AboPauseTest.php @@ -119,6 +119,7 @@ it('lehnt die Pause vor Erreichen der Mindestlaufzeit ab', function (): void { }); it('lehnt die Pause innerhalb der Sperrfrist vor der Ausfuehrung ab', function (): void { + Setting::setContentBySlug('abo-lock-days-pause-cancel', 3, 'int'); $user = aptMakeUser(); $abo = aptMakeAbo($user, nextDate: '2026-07-24'); // in 2 Tagen, Sperre = 3 Tage @@ -126,6 +127,16 @@ it('lehnt die Pause innerhalb der Sperrfrist vor der Ausfuehrung ab', function ( ->and($abo->fresh()->pause_count)->toBe(0); }); +it('lehnt die Pause ohne Vorlaufsperre nur am Ausfuehrungstag selbst ab (A2)', function (): void { + $user = aptMakeUser(); + $tomorrow = aptMakeAbo($user, nextDate: '2026-07-23'); + $today = aptMakeAbo(aptMakeUser(), nextDate: '2026-07-22'); + + expect(aptPause($tomorrow))->toBeTrue() + ->and(aptPause($today))->toBeFalse() + ->and(session('alert-error'))->toBe(__('abo.error_pause_locked_zero', ['lock' => 0, 'days' => 0, 'date' => '22.07.2026'])); +}); + it('erlaubt die Pause nur einmal pro rollierendem Intervall', function (): void { $user = aptMakeUser(); diff --git a/tests/Feature/AboRepositoryDateChangeTest.php b/tests/Feature/AboRepositoryDateChangeTest.php index 6c890da6..4fba2978 100644 --- a/tests/Feature/AboRepositoryDateChangeTest.php +++ b/tests/Feature/AboRepositoryDateChangeTest.php @@ -1,6 +1,7 @@ getRawOriginal('next_date'))->toBe('2026-03-20'); }); -it('behält die User-Sperrfrist für normale Abo-Änderungen bei', function () { +it('behält eine gepflegte User-Sperrfrist für normale Abo-Änderungen bei', function () { Carbon::setTestNow('2026-03-19 12:00:00'); + Setting::setContentBySlug('abo-lock-days-change', 10, 'int'); $abo = createRepositoryTestAbo([ 'abo_interval' => 5, @@ -142,6 +144,30 @@ it('behält die User-Sperrfrist für normale Abo-Änderungen bei', function () { expect($abo->getRawOriginal('next_date'))->toBe('2026-03-20'); }); +it('erlaubt ohne Vorlaufsperre die Aenderung noch am Vortag der Lieferung (A2)', function () { + Carbon::setTestNow('2026-03-19 12:00:00'); + + $abo = createRepositoryTestAbo([ + 'abo_interval' => 5, + 'next_date' => '2026-03-20', + ]); + + $this->actingAs($abo->user); + $this->repository->setModel($abo); + + $result = $this->repository->update([ + 'action' => 'abo_update_settings', + 'id' => $abo->id, + 'view' => 'me', + 'abo_interval' => 20, + 'abo_is_active' => 'true', + ]); + + expect($result)->toBeTrue(); + expect($abo->refresh()->abo_interval)->toBe(20); + expect($abo->getRawOriginal('next_date'))->toBe('2026-03-20'); +}); + /** * Hilfsfunktion zum Aufruf privater Methoden. * diff --git a/tests/Feature/PaylineForecastServiceTest.php b/tests/Feature/PaylineForecastServiceTest.php index 18c57fc9..ea064cb1 100644 --- a/tests/Feature/PaylineForecastServiceTest.php +++ b/tests/Feature/PaylineForecastServiceTest.php @@ -131,11 +131,29 @@ it('berechnet die Prognose kumuliert je Ausfuehrungstermin inklusive eigener Abo $forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0); expect($forecast['base'])->toBe(100.0) - ->and($forecast['days'])->toHaveCount(4) - ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false]) + ->and($forecast['days'])->toHaveCount(5) + ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false, 'is_month_end' => false]) ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0, 'total' => 150.0]) ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 50.0, 'total' => 150.0]) - ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 60.0, 'total' => 160.0]); + ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 60.0, 'total' => 160.0]) + ->and($forecast['days'][4])->toMatchArray(['day' => 31, 'upcoming' => 60.0, 'total' => 160.0, 'is_month_end' => true]); +}); + +it('nimmt Abos nach dem 25. in die Monatsende-Kachel auf (A1: freie Liefertage)', function (): void { + Carbon::setTestNow(Carbon::parse('2026-07-02')); + + $root = pflMakeUser(); + $anna = pflMakeUser($root->id); + pflSeedContext($root, [$anna->id]); + + pflMakeAbo($anna->id, 'ot', '2026-07-25', 20); + pflMakeAbo($anna->id, 'ot', '2026-07-28', 30); + pflMakeAbo($anna->id, 'ot', '2026-07-31', 40); + + $forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0); + + expect($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0]) + ->and($forecast['days'][4])->toMatchArray(['day' => 31, 'date' => '31.07.2026', 'upcoming' => 90.0, 'total' => 90.0, 'is_month_end' => true]); }); it('markiert vergangene Termine und zaehlt nur noch offene Ausfuehrungen', function (): void { @@ -497,7 +515,7 @@ it('prognostiziert die Liefertage ausschliesslich aus den eigenen Kundenabos', f $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($root); expect($forecast['base'])->toBe(0.0) - ->and($forecast['days'])->toHaveCount(4) + ->and($forecast['days'])->toHaveCount(5) ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'total' => 0.0]) ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 30.0]) ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 30.0, 'total' => 30.0]) @@ -557,7 +575,8 @@ it('haengt Buchungen nach dem letzten Liefertag an die letzte Kachel', function $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater); expect($forecast['days'][0]['total'])->toBe(10.0) - ->and($forecast['days'][3]['total'])->toBe(35.0) + ->and($forecast['days'][3]['total'])->toBe(10.0) + ->and($forecast['days'][4]['total'])->toBe(35.0) ->and($forecast['base'])->toBe(35.0); });