Abo (A1+A2): Liefertag frei waehlbar 1-31, Aenderung nur nach vorn, Sperre erst am Ausfuehrungstag

A1: Jeder Monatstag ist als Liefertag erlaubt (UserAbo::allowedDeliveryDays),
Tage 29-31 liefern in kuerzeren Monaten am letzten Tag. Eine Stelle
AboHelper::dateForDayInMonth klammert; setNextDate, Erstausfuehrung,
Admin-Termin und Pause rechnen darueber, der Folgemonat immer aus dem
Liefertag (31.01 -> 28.02 -> 31.03). Liefertag-Aenderung gilt fuer den
laufenden Zyklus und nur nach vorn: ist der Tag im Referenzmonat vorbei,
wird abgelehnt, der Monat verschiebt sich nie (Rueckfrage R1).
Admin-Filter "naechste Ausfuehrung" als Datumsbereich. Prognose-Kacheln
bekommen die Kachel Monatsende, Team-Abos rechnen bis Monatsende.

A2: Sperrfristen Default 0, Setting 0 ist ausdruecklich 0 (fiel bisher auf
den Default zurueck). Bedingung Kalendertage <= Sperre, zentral in
AboHelper::isWithinLock: am Ausfuehrungstag ab 0:00 Uhr fixiert, Vortag bis
23:59 Uhr frei, fuer Liefertag, Produkte, Pause und Kuendigung (R2). Texte
mit _zero-Varianten ueber AboHelper::lockMessage.

Sprachdateien de/en/es/fr, neuer AboDeliveryDayTest, bestehende Tests
angepasst, Plan-Status mit Deploy-Schritt (Settings auf 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-16 11:28:55 +00:00
parent ec3ecadc5d
commit 66c6ae05fb
30 changed files with 638 additions and 185 deletions

View file

@ -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)

View file

@ -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()) {

View file

@ -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') {

View file

@ -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';

View file

@ -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']
);
}
/**

View file

@ -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,
];
}

View file

@ -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;
}

View file

@ -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.<key>_zero`), sonst der Text mit Vorlauf
* (`abo.<key>`, 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)

View file

@ -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)

View file

@ -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.

View file

@ -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',
];

View file

@ -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)',
];

View file

@ -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',
];

View file

@ -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)',
];

View file

@ -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',
];

View file

@ -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)',
];

View file

@ -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',
];

View file

@ -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)',
];

View file

@ -43,10 +43,7 @@
</div>
@elseif ($pcLocked && $pcNextDate)
<div class="alert alert-warning mb-0">
{{ __('abo.pause_cancel_locked_hint', [
'date' => $pcNextDate->format(Util::formatDateDB()),
'lock' => App\Repositories\AboRepository::getLockDaysPauseCancel(),
]) }}
{{ App\Services\AboHelper::lockMessage('pause_cancel_locked_hint', $user_abo, App\Repositories\AboRepository::getLockDaysPauseCancel()) }}
</div>
@else
<div class="row">

View file

@ -69,14 +69,12 @@
</select>
</div>
</div>
<div class="col-6 col-md-3 col-lg-2 mb-2">
<label class="form-label" for="filter_next_day">{{ __('tables.next_date') }}</label>
<select class="custom-select" name="filter_next_day" id="filter_next_day">
<option value="">{{ __('tables.all') }}</option>
@foreach ($filter_next_days as $day)
<option value="{{ $day }}" @if((string) get_user_attr('filter_next_day') === (string) $day) selected @endif>{{ \App\Services\HTMLHelper::getAboStrLang($day) }}</option>
@endforeach
</select>
<div class="col-12 col-md-6 col-lg-4 mb-2">
<label class="form-label">{{ __('tables.next_date') }}</label>
<div class="d-flex border rounded p-1" style="gap: 6px;">
<input type="date" class="form-control" name="filter_next_from" id="filter_next_from" value="{{ get_user_attr('filter_next_from') }}" aria-label="{{ __('abo.filter_next_from') }}" placeholder="{{ __('abo.filter_next_from') }}">
<input type="date" class="form-control" name="filter_next_to" id="filter_next_to" value="{{ get_user_attr('filter_next_to') }}" aria-label="{{ __('abo.filter_next_to') }}" placeholder="{{ __('abo.filter_next_to') }}">
</div>
</div>
<div class="col-12 col-sm-6 col-md-3 col-lg-3 mb-2">
<label class="form-label" for="filter_member_id">{{ __('tables.adviser') }}</label>
@ -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();
});
});

View file

@ -22,10 +22,7 @@
@if($aboChangeLocked)
<div class="alert alert-warning">
{{ __('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()) }}
</div>
@endif

View file

@ -268,11 +268,11 @@
<div class="text-muted small mb-3 mt-n2">{{ __('home.payline_forecast_basis') }}</div>
<div class="row">
@foreach ($s['payline_forecast']['days'] as $forecastDay)
<div class="col-sm-6 col-lg-3 mb-3">
<div class="col-6 col-lg mb-3">
<div class="card h-100 {{ $forecastDay['is_past'] ? 'bg-light' : '' }}">
<div class="card-body py-3 text-center">
<div class="text-muted small">
{{ __('home.forecast_day', ['date' => $forecastDay['date']]) }}
{{ ! empty($forecastDay['is_month_end']) ? __('home.forecast_month_end', ['date' => $forecastDay['date']]) : __('home.forecast_day', ['date' => $forecastDay['date']]) }}
</div>
<div class="font-weight-bold text-large">{{ $fmt($forecastDay['total']) }}</div>
@if ($forecastDay['upcoming'] > 0)

View file

@ -24,11 +24,11 @@
<div class="text-muted small mb-3">{{ __('abo.customer_abo_forecast_hint') }}</div>
<div class="row">
@foreach ($customerAboForecast['days'] as $forecastDay)
<div class="col-sm-6 col-lg-3 mb-2">
<div class="col-6 col-lg mb-2">
<div class="card h-100 {{ $forecastDay['is_past'] ? 'bg-light' : '' }}">
<div class="card-body py-3 text-center">
<div class="text-muted small">
{{ __('home.forecast_day', ['date' => $forecastDay['date']]) }}
{{ ! empty($forecastDay['is_month_end']) ? __('home.forecast_month_end', ['date' => $forecastDay['date']]) : __('home.forecast_day', ['date' => $forecastDay['date']]) }}
</div>
<div class="font-weight-bold text-large">
{{ \App\Services\Util::formatNumber($forecastDay['total']) }}

View file

@ -95,7 +95,7 @@
<th class="text-center">{{ __('abo.team_total') }}</th>
<th class="text-right">{{ __('abo.abo_points_per_cycle') }}</th>
<th class="text-right">{{ __('abo.abo_points_billed', ['month' => $currentMonthName]) }}</th>
<th class="text-right" title="{{ __('abo.abo_points_expected_hint', ['day' => 25]) }}">{{ __('abo.abo_points_expected', ['day' => 25]) }}</th>
<th class="text-right" title="{{ __('abo.abo_points_expected_hint') }}">{{ __('abo.abo_points_expected') }}</th>
</tr>
</thead>
<tbody>

View file

@ -0,0 +1,204 @@
<?php
use App\Models\Setting;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
use App\Services\AboHelper;
use App\Services\HTMLHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Tickets A1/A2 (Entwicklungsplan September 2026): jeder Monatstag 1-31 als
* Liefertag, Klammerung auf das Monatsende, Aenderung nur "nach vorn" im
* laufenden Zyklus (Rueckfrage R1), Sperre erst am Ausfuehrungstag ab 0:00 Uhr.
*/
uses(TestCase::class, RefreshDatabase::class);
afterEach(fn () => 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, '<option'))->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);
});

View file

@ -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 () {

View file

@ -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']);

View file

@ -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();

View file

@ -1,6 +1,7 @@
<?php
use App\Models\Country;
use App\Models\Setting;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
@ -118,8 +119,9 @@ it('erlaubt Admins den nächsten Ausführungstermin ohne User-Sperrfrist direkt
expect($abo->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.
*

View file

@ -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);
});