F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der Backfill nimmt das Aktivierungsdatum nur noch ohne jede Registrierungsbestellung (offene Rechnung bleibt leer). F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel (Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig. F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit; eine Ausfuehrung loescht auch den paused_by_*-Grund. F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits committeten Test nachgezogen. F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false). Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal. Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
412 lines
17 KiB
PHP
412 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Repositories;
|
|
|
|
use App\Models\Setting;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboStatusHistory;
|
|
use App\Services\AboHelper;
|
|
use Carbon\Carbon;
|
|
|
|
class AboRepository extends BaseRepository
|
|
{
|
|
/**
|
|
* 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;
|
|
|
|
/** Gleiche Regel fuer Pausieren/Kuendigen (Rueckfrage R2, 14.09.: ja). */
|
|
private const DEFAULT_LOCK_DAYS_PAUSE_CANCEL = 0;
|
|
|
|
/**
|
|
* Sperrfrist (Kalendertage) vor der Ausführung, ab der das Bearbeiten des Abos
|
|
* (Intervall/Produkte) blockiert wird. Setting `abo-lock-days-change`:
|
|
* nicht gepflegt/leer => Default, `0` => ausdruecklich keine Vorlaufsperre.
|
|
*/
|
|
public static function getLockDaysChange(): int
|
|
{
|
|
return self::lockDaysFromSetting('abo-lock-days-change', self::DEFAULT_LOCK_DAYS_CHANGE);
|
|
}
|
|
|
|
/**
|
|
* Sperrfrist (Kalendertage) vor der Ausführung, ab der das Pausieren/Kündigen
|
|
* des Abos blockiert wird. Setting `abo-lock-days-pause-cancel`, Semantik
|
|
* wie {@see getLockDaysChange()}.
|
|
*/
|
|
public static function getLockDaysPauseCancel(): int
|
|
{
|
|
return self::lockDaysFromSetting('abo-lock-days-pause-cancel', self::DEFAULT_LOCK_DAYS_PAUSE_CANCEL);
|
|
}
|
|
|
|
private static function lockDaysFromSetting(string $slug, int $default): int
|
|
{
|
|
$raw = Setting::getContentBySlug($slug);
|
|
|
|
if ($raw === false || $raw === null || $raw === '') {
|
|
return $default;
|
|
}
|
|
|
|
return max(0, (int) $raw);
|
|
}
|
|
|
|
public function __construct()
|
|
{
|
|
// $this->model = $model;
|
|
}
|
|
|
|
public function setModel(UserAbo $model)
|
|
{
|
|
$this->model = $model;
|
|
}
|
|
|
|
public function update($data)
|
|
{
|
|
// Der Kanal wird für den gesamten Schreibvorgang gesetzt, damit auch die
|
|
// zentral am Model protokollierten Zustandswechsel wissen, aus welchem
|
|
// Formular sie stammen ({@see UserAbo::logStatusTransition()}).
|
|
return UserAbo::withStatusLogView($data['view'] ?? null, fn () => $this->updateWithinStatusLogView($data));
|
|
}
|
|
|
|
private function updateWithinStatusLogView($data)
|
|
{
|
|
if (isset($data['action'])) {
|
|
if ($data['action'] === 'abo_update_settings') {
|
|
if ($this->validate($data)) {
|
|
// Pause ist eine eigenständige Aktion: sie verschiebt nur next_date
|
|
// und darf nicht von der anschließenden Liefertag-Neuberechnung
|
|
// überschrieben werden.
|
|
if (isset($data['abo_pause']) && $data['abo_pause'] == 'true') {
|
|
return $this->pauseOneMonth($data);
|
|
}
|
|
|
|
// Kündigung ist wie die Pause eine eigenständige Aktion: nach dem
|
|
// Statuswechsel gibt es keinen nächsten Liefertermin mehr, der
|
|
// neu berechnet oder als Hinweis ausgegeben werden müsste.
|
|
if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') {
|
|
$this->updateStatus($data);
|
|
|
|
if ((int) $this->model->status === 4) {
|
|
\Session()->flash('alert-success', __('abo.cancel_success'));
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
$this->updateStatus($data);
|
|
$this->model->abo_interval = (int) $data['abo_interval'];
|
|
$nextDate = $this->isAdminUpdate($data)
|
|
? $this->calculateAdminNextDate($data)
|
|
: $this->calculateNewNextDate((int) $data['abo_interval']);
|
|
$this->model->next_date = $nextDate;
|
|
// Laufende Pause: ihr Ende ist der naechste Liefertermin — wandert
|
|
// der, wandert das Pausenende mit (Audit 18.09., F3).
|
|
if ($this->model->getRawOriginal('paused_until')) {
|
|
$this->model->paused_until = $nextDate->copy()->startOfDay();
|
|
}
|
|
$this->model->save();
|
|
|
|
$daysUntilNext = AboHelper::calendarDaysUntil(now(), $nextDate);
|
|
\Session()->flash('alert-warning', __('abo.warning_next_date_info', ['days' => $daysUntilNext, 'date' => $nextDate->format(\Util::formatDateDB())]));
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function create($data) {}
|
|
|
|
/**
|
|
* Prüft die Regeln für Kündigung, Deaktivierung und Reaktivierung und setzt den
|
|
* neuen Zustand. Der Protokolleintrag entsteht nicht hier, sondern zentral in
|
|
* {@see UserAbo::logStatusTransition()} — sonst fehlt er in jedem Schreibpfad,
|
|
* der nicht über dieses Formular läuft. Nur die Pause protokolliert selbst, weil
|
|
* sie den Aktiv-Zustand nicht ändert und die Datumsverschiebung mitführt.
|
|
*/
|
|
private function updateStatus($data)
|
|
{
|
|
$isAdminUpdate = $this->isAdminUpdate($data);
|
|
|
|
// Handle cancellation
|
|
if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') {
|
|
// Mindestlaufzeit serverseitig durchsetzen — die UI blendet den Button nur aus
|
|
if (! $isAdminUpdate && ! AboHelper::canCancelAbo($this->model, $data['view'] ?? 'user')) {
|
|
\Session()->flash('alert-error', __('abo.info_min_duration_orders_left', [
|
|
'count' => max(0, (int) AboHelper::getAboMinDuration() - $this->model->getCountPaidOrders()),
|
|
]));
|
|
|
|
return;
|
|
}
|
|
|
|
// Sperre vor der Ausfuehrung (Default: ab 0:00 Uhr des Ausfuehrungstags)
|
|
if (! $isAdminUpdate && AboHelper::isWithinLock($this->model, self::getLockDaysPauseCancel())) {
|
|
\Session()->flash('alert-error', AboHelper::lockMessage('error_cancel_locked', $this->model, self::getLockDaysPauseCancel()));
|
|
|
|
return;
|
|
}
|
|
|
|
// Die Folgen der Kuendigung (Ticket A4) muessen bestaetigt sein — auch
|
|
// serverseitig, nicht nur ueber den deaktivierten Button (Audit 18.09.).
|
|
if (! $isAdminUpdate && ! filter_var($data['abo_cancel_confirm'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
|
|
\Session()->flash('alert-error', __('abo.cancel_confirm_required'));
|
|
|
|
return;
|
|
}
|
|
|
|
// Status 4 = abo_cancel (storniert/gekündigt)
|
|
$this->model->status = 4;
|
|
$this->model->active = false;
|
|
$this->model->cancel_date = now();
|
|
$this->model->save();
|
|
|
|
return;
|
|
}
|
|
|
|
// A01: Im Selbstbedienungspfad ist der Aktiv-Zustand kein Eingabefeld. Das
|
|
// Formular spiegelt ihn nur in einem versteckten Feld zurück; würde er von
|
|
// dort übernommen, liesse sich die Mindestlaufzeit durch dauerhafte
|
|
// Deaktivierung umgehen und der Ein-Zyklus-Pausenpfad samt seiner Sperren
|
|
// ganz überspringen. Zustandswechsel laufen hier ausschliesslich über die
|
|
// dafür zugelassenen Aktionen Pause und Kündigung.
|
|
$active = $isAdminUpdate
|
|
? (bool) ($data['abo_is_active'] ?? false)
|
|
: (bool) $this->model->active;
|
|
// Sperre vor der Ausfuehrung: keine Deaktivierung mehr
|
|
if (! $isAdminUpdate && $this->model->active && ! $active && AboHelper::isWithinLock($this->model, self::getLockDaysPauseCancel())) {
|
|
\Session()->flash('alert-error', AboHelper::lockMessage('error_pause_locked', $this->model, self::getLockDaysPauseCancel()));
|
|
|
|
return;
|
|
}
|
|
// if status is active and active is false, set status to inactive
|
|
if ($this->model->active && ! $active) {
|
|
if ($this->model->status == 2) { // okay
|
|
$this->model->status = 6; // inactive
|
|
$this->model->active = false;
|
|
$this->model->save();
|
|
}
|
|
}
|
|
if (! $this->model->active && $active) {
|
|
if ($this->model->status == 6) { // inactive
|
|
$this->model->status = 2; // okay
|
|
$this->model->active = true;
|
|
$this->model->save();
|
|
}
|
|
}
|
|
$this->model->active = $active;
|
|
}
|
|
|
|
/**
|
|
* Pausiert das Abo um genau einen Ausführungs-Zyklus: next_date wandert auf den
|
|
* gleichen Liefertag des Folgemonats, das Abo bleibt aktiv (Status 2).
|
|
* Regeln (außer Admin): Sperrfrist vor der Ausführung, Mindestlaufzeit und
|
|
* max. 1 Pause pro X Monate ({@see AboHelper::canPauseAbo()}).
|
|
*/
|
|
private function pauseOneMonth(array $data): bool
|
|
{
|
|
// Zeilen-Lock + frisches Modell: zwei parallele Pause-Requests dürfen next_date
|
|
// nicht beide auf Basis des alten Standes verschieben (Doppel-Pause).
|
|
return \DB::transaction(function () use ($data): bool {
|
|
$this->model = UserAbo::whereKey($this->model->id)->lockForUpdate()->firstOrFail();
|
|
|
|
return $this->pauseOneMonthLocked($data);
|
|
});
|
|
}
|
|
|
|
private function pauseOneMonthLocked(array $data): bool
|
|
{
|
|
$isAdminUpdate = $this->isAdminUpdate($data);
|
|
|
|
if (! $this->model->active || (int) $this->model->status !== 2 || ! $this->model->getRawOriginal('next_date')) {
|
|
\Session()->flash('alert-error', __('abo.error_pause_not_active'));
|
|
|
|
return false;
|
|
}
|
|
|
|
if (! $isAdminUpdate) {
|
|
if (AboHelper::isWithinLock($this->model, self::getLockDaysPauseCancel())) {
|
|
\Session()->flash('alert-error', AboHelper::lockMessage('error_pause_locked', $this->model, self::getLockDaysPauseCancel()));
|
|
|
|
return false;
|
|
}
|
|
|
|
if (! AboHelper::canCancelAbo($this->model, $data['view'] ?? 'user')) {
|
|
\Session()->flash('alert-error', __('abo.info_min_duration_orders_left', ['count' => (int) AboHelper::getAboMinDuration() - $this->model->getCountPaidOrders()]));
|
|
|
|
return false;
|
|
}
|
|
|
|
if (($nextPossible = AboHelper::nextPossiblePauseDate($this->model)) !== null) {
|
|
\Session()->flash('alert-error', __('abo.error_pause_interval', ['months' => AboHelper::getPauseIntervalMonths(), 'date' => $nextPossible->format(\Util::formatDateDB())]));
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$previousNextDate = Carbon::parse($this->model->getRawOriginal('next_date'));
|
|
$newNextDate = AboHelper::setNextDate($previousNextDate, (int) $this->model->abo_interval);
|
|
|
|
$this->model->next_date = $newNextDate;
|
|
$this->model->last_pause_date = now();
|
|
$this->model->pause_count = (int) $this->model->pause_count + 1;
|
|
// Ticket A3: Pause sichtbar machen — bis wann und durch wen.
|
|
$this->model->paused_until = $newNextDate->copy()->startOfDay();
|
|
$this->model->status_reason = UserAbo::REASON_PREFIX_PAUSED.UserAbo::reasonActorFromView($data['view'] ?? null);
|
|
$this->model->save();
|
|
|
|
$this->logStatusHistory(UserAboStatusHistory::ACTION_PAUSED, $data, $previousNextDate, $newNextDate);
|
|
|
|
\Session()->flash('alert-success', __('abo.pause_success', ['date' => $newNextDate->format(\Util::formatDateDB())]));
|
|
|
|
return true;
|
|
}
|
|
|
|
private function logStatusHistory(string $action, array $data, ?Carbon $previousNextDate = null, ?Carbon $newNextDate = null): void
|
|
{
|
|
UserAboStatusHistory::create([
|
|
'user_abo_id' => $this->model->id,
|
|
'action' => $action,
|
|
'view' => $data['view'] ?? null,
|
|
'actor_user_id' => \Auth::id(),
|
|
'previous_next_date' => $previousNextDate,
|
|
'new_next_date' => $newNextDate,
|
|
]);
|
|
}
|
|
|
|
private function validate($data)
|
|
{
|
|
if ($data['view'] !== 'admin' && $data['view'] !== 'portal') {
|
|
if ($this->model->is_for === 'me' && $this->model->user_id !== \Auth::user()->id) {
|
|
\Session()->flash('alert-error', 'Unauthorized action. User ID does not match.');
|
|
|
|
return false;
|
|
}
|
|
if ($this->model->is_for === 'ot' && $this->model->member_id !== \Auth::user()->id) {
|
|
\Session()->flash('alert-error', 'Unauthorized action. User ID does not match.');
|
|
|
|
return false;
|
|
}
|
|
if ($data['view'] === 'me' && $this->model->is_for !== 'me') {
|
|
\Session()->flash('alert-error', 'Unauthorized action. Is not for me');
|
|
|
|
return false;
|
|
}
|
|
if ($data['view'] === 'ot' && $this->model->is_for !== 'ot') {
|
|
\Session()->flash('alert-error', 'Unauthorized action. Is not your customer');
|
|
|
|
return false;
|
|
}
|
|
}
|
|
if (! isset($data['abo_interval']) || ! UserAbo::isAllowedDeliveryDay($data['abo_interval'])) {
|
|
\Session()->flash('alert-error', __('abo.error_abo_interval'));
|
|
|
|
return false;
|
|
}
|
|
|
|
if ($this->isAdminUpdate($data)) {
|
|
if (! isset($data['abo_next_month']) || ! in_array($data['abo_next_month'], $this->getAdminExecutionMonths(), true)) {
|
|
\Session()->flash('alert-error', __('abo.error_next_date'));
|
|
|
|
return false;
|
|
}
|
|
|
|
if ($this->calculateAdminNextDate($data)->startOfDay()->lt(now()->startOfDay())) {
|
|
\Session()->flash('alert-error', __('abo.error_next_date'));
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Die Change-Sperre (Liefertag/Intervall) greift NUR, wenn der Liefertag tatsächlich
|
|
// geändert wird. Reines Pausieren/Kündigen (Intervall unverändert) unterliegt
|
|
// ausschließlich der eigenen Sperrfrist in updateStatus() + der Mindestlaufzeit.
|
|
$intervalChanged = (int) $data['abo_interval'] !== (int) $this->model->abo_interval;
|
|
|
|
if ($intervalChanged) {
|
|
// Sperre vor der anstehenden Ausfuehrung (Default: ab 0:00 Uhr des Ausfuehrungstags)
|
|
if (AboHelper::isWithinLock($this->model, self::getLockDaysChange())) {
|
|
\Session()->flash('alert-error', AboHelper::lockMessage('error_change_locked', $this->model, self::getLockDaysChange()));
|
|
|
|
return false;
|
|
}
|
|
|
|
// Ticket A1 / Rueckfrage R1: Die Aenderung gilt fuer den laufenden Zyklus
|
|
// (Monat des naechsten Termins) und nur "nach vorn". Ist der gewuenschte Tag in
|
|
// diesem Monat schon vorbei, wird abgelehnt — sonst wuerde die Lieferung dieses
|
|
// Monats entfallen oder in den Folgemonat rutschen. Der Monat verschiebt sich nie.
|
|
$newNextDate = $this->calculateNewNextDate((int) $data['abo_interval']);
|
|
$daysUntilNewDate = (int) Carbon::today()->diffInDays($newNextDate, false);
|
|
|
|
if ($daysUntilNewDate <= 0) {
|
|
\Session()->flash('alert-error', __('abo.error_abo_interval_passed', [
|
|
'day' => (int) $data['abo_interval'],
|
|
'next' => $this->model->getRawOriginal('next_date') ? Carbon::parse($this->model->getRawOriginal('next_date'))->format(\Util::formatDateDB()) : '-',
|
|
]));
|
|
|
|
return false;
|
|
}
|
|
|
|
if ($daysUntilNewDate <= self::getLockDaysChange()) {
|
|
\Session()->flash('alert-error', __('abo.error_abo_interval_too_soon', ['days' => $daysUntilNewDate, 'lock' => self::getLockDaysChange()]));
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Neuer Liefertermin = gewaehlter Tag im Referenzmonat (geklammert auf das
|
|
* Monatsende). Referenzmonat ist der Monat des aktuellen next_date, wenn der
|
|
* schon im Folgemonat liegt (die Lieferung dieses Monats ist gelaufen), sonst
|
|
* der laufende Monat. Ob der Termin noch in der Zukunft liegt, prueft
|
|
* {@see validate()} — der Monat wird hier nie verschoben.
|
|
*/
|
|
private function calculateNewNextDate(int $aboInterval): Carbon
|
|
{
|
|
$referenceMonth = Carbon::today()->startOfMonth();
|
|
|
|
if ($this->model->next_date) {
|
|
$currentNextDate = Carbon::parse($this->model->getRawOriginal('next_date') ?: $this->model->next_date);
|
|
|
|
if ($currentNextDate->format('Y-m') > $referenceMonth->format('Y-m')) {
|
|
$referenceMonth = $currentNextDate->copy()->startOfMonth();
|
|
}
|
|
}
|
|
|
|
return AboHelper::dateForDayInMonth($referenceMonth, $aboInterval);
|
|
}
|
|
|
|
private function calculateAdminNextDate(array $data): Carbon
|
|
{
|
|
return AboHelper::dateForDayInMonth(
|
|
Carbon::createFromFormat('Y-m-d', $data['abo_next_month'].'-01'),
|
|
(int) $data['abo_interval']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function getAdminExecutionMonths(): array
|
|
{
|
|
return collect(range(0, 3))
|
|
->map(fn (int $offset): string => now()->copy()->startOfMonth()->addMonths($offset)->format('Y-m'))
|
|
->all();
|
|
}
|
|
|
|
private function isAdminUpdate(array $data): bool
|
|
{
|
|
return ($data['view'] ?? null) === 'admin';
|
|
}
|
|
}
|