Audit 18.09.: Zahlungsdatum nur bei Zahlung, Neupartner-IDs im Snapshot, Pausenende folgt dem Termin

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>
This commit is contained in:
Kevin 2026-09-18 08:59:45 +00:00
parent b7e8bbd951
commit 03416b0afe
27 changed files with 560 additions and 21 deletions

View file

@ -196,9 +196,32 @@ class AboStatusExplainer
return $this->latestHistory($abo, $action)?->created_at?->copy();
}
/**
* Letzter Protokolleintrag je Aktion — pro Abo und Aktion nur einmal geladen,
* aus der vorgeladenen Relation, wenn vorhanden (Listen: `with('status_histories')`).
*
* @var array<string, UserAboStatusHistory|null>
*/
private array $historyMemo = [];
private function latestHistory(UserAbo $abo, string $action): ?UserAboStatusHistory
{
return $abo->status_histories()->where('action', $action)->orderByDesc('id')->first();
$key = $abo->id.':'.$action;
if (array_key_exists($key, $this->historyMemo)) {
return $this->historyMemo[$key];
}
if ($abo->relationLoaded('status_histories')) {
$entry = $abo->status_histories
->filter(fn (UserAboStatusHistory $history) => $history->action === $action)
->sortByDesc('id')
->first();
} else {
$entry = $abo->status_histories()->where('action', $action)->orderByDesc('id')->first();
}
return $this->historyMemo[$key] = $entry;
}
private function actorLabel(?string $actor): string

View file

@ -611,13 +611,18 @@ class BackofficeDashboardService
$customerAboIds = $this->aboIds($userIds, 'member_id', 'ot');
$partnerAboIdsNew = $this->aboIds($userIds, 'user_id', 'me', $month, $year);
$customerAboIdsNew = $this->aboIds($userIds, 'member_id', 'ot', $month, $year);
// Neupartner ebenfalls als IDs festhalten: die Regel (Zahlungsmonat seit
// 16.09.2026) kann sich aendern, die Liste hinter der eingefrorenen Kachel
// muss trotzdem genau die damals gezaehlten Partner zeigen (Audit 18.09., F2).
$newPartnerIds = $this->newPartnerIds($users, $month, $year);
return [
'line' => $line,
'label' => 'Linie '.$line,
'user_ids' => $userIds,
'consultants' => $this->activeConsultants($users),
'new_partners' => $this->newPartners($users, $month, $year),
'new_partners' => count($newPartnerIds),
'new_partner_ids' => $newPartnerIds,
'team_partner_abos' => count($partnerAboIds),
'team_partner_abos_new' => count($partnerAboIdsNew),
'team_customer_abos' => count($customerAboIds),
@ -701,10 +706,22 @@ class BackofficeDashboardService
* @param Collection<int, User> $users
*/
private function newPartners(Collection $users, int $month, int $year): int
{
return count($this->newPartnerIds($users, $month, $year));
}
/**
* @param Collection<int, User> $users
* @return int[]
*/
private function newPartnerIds(Collection $users, int $month, int $year): array
{
return $users
->filter(fn (User $user) => $user->isNewPartnerIn($month, $year))
->count();
->pluck('id')
->map(fn ($id) => (int) $id)
->values()
->all();
}
private function activeAboQuery(): Builder

View file

@ -49,7 +49,7 @@ class BackofficeDrilldownService
$rows = match ($metric) {
'consultants' => $this->consultantRows($userIds),
'new_partners' => $this->newPartnerRows($userIds, $month, $year),
'new_partners' => $this->newPartnerRows($userIds, $month, $year, $frozen !== null ? $this->frozenNewPartnerIds($frozen, $line) : null, $frozen !== null),
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric),
@ -149,13 +149,19 @@ class BackofficeDrilldownService
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function newPartnerRows(array $userIds, int $month, int $year): array
private function newPartnerRows(array $userIds, int $month, int $year, ?array $frozenIds = null, bool $frozen = false): array
{
// Eingefrorener Monat: exakt die im Snapshot gezaehlten Partner. Aeltere
// Snapshots ohne gespeicherte IDs wurden mit der Regel "Registrierungsmonat"
// gebaut — die Liste rechnet dann mit derselben Regel, sonst zeigt die
// Kachel 1 und die Liste 0 (Audit 18.09., F2).
$partners = User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->newPartnerIn($month, $year)
->whereIn('id', $frozenIds ?? $userIds)
->when($frozenIds === null && $frozen, fn (Builder $query) => $query->newPartnerInByActiveDate($month, $year))
->when($frozenIds === null && ! $frozen, fn (Builder $query) => $query->newPartnerIn($month, $year))
->orderBy('partner_paid_at')
->orderBy('active_date')
->get();
$sponsors = User::query()
@ -274,6 +280,34 @@ class BackofficeDrilldownService
return array_values(array_unique($ids));
}
/**
* Neupartner einer Linie zum Monatsabschluss; null, wenn der Snapshot die IDs
* noch nicht kennt (gebaut vor dem 18.09.2026) — dann gilt die damalige Regel.
*
* @param array<int, array<string, mixed>> $frozen
* @return int[]|null
*/
private function frozenNewPartnerIds(array $frozen, int $line): ?array
{
$ids = [];
foreach ($frozen as $index => $row) {
if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) {
continue;
}
if (! array_key_exists('new_partner_ids', $row)) {
return null;
}
foreach ($row['new_partner_ids'] as $id) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**
* Abos einer Linie zum Monatsabschluss; Linie 0 = das ganze Team. Gibt es zu der
* Kennzahl keine eingefrorene Liste, wird null zurueckgegeben (Live-Auswertung).

View file

@ -227,7 +227,13 @@ class Payment
$user->wizard = 100;
// Erste Kontozahlung = Zeitpunkt, ab dem der Partner in der
// Neupartner-Statistik zaehlt. Verlaengerungen aendern ihn nicht.
UserUtil::markPartnerPaid($user);
// Nur bei bestaetigter Zahlung (Audit 18.09., F1): der Rechnungskauf
// ruft diesen Pfad mit $paid = false auf und aktiviert das Konto
// vorab — bezahlt ist er erst, wenn die Rechnung beglichen wird
// (ManualPaymentService, dann $paid = true).
if ($paid) {
UserUtil::markPartnerPaid($user);
}
// only date is > now and acount is deactive.
if ($date > \Carbon::now()) {
if ($user->active === 0) {

View file

@ -301,8 +301,13 @@ class UserUtil
* 1. die Punktebuchung der ersten bezahlten Registrierungsbestellung
* (`payment_for = 1`) — sie entsteht im Moment der Zahlung;
* 2. ersatzweise das Aenderungsdatum dieser Bestellung;
* 3. ohne Registrierungsbestellung (Konto vom Admin freigeschaltet) das
* Aktivierungsdatum — einen Zahlungszeitpunkt gibt es dort nicht.
* 3. ohne jede Registrierungsbestellung (Konto vom Admin freigeschaltet) das
* Aktivierungsdatum — einen Zahlungszeitpunkt gibt es dort nicht. Gibt es
* eine Registrierungsbestellung, die nur noch nicht bezahlt ist (offene
* Rechnung), bleibt der Wert leer: er wird bei der Zahlung gesetzt
* (Audit 18.09., F1).
*
* Schreibt ohne Events und ohne `updated_at` anzufassen (Audit 18.09., F5).
*
* Absichtlich NICHT `payment_account` minus ein Jahr: bei Verlaengerungen
* wird das Konto vom alten Ablaufdatum aus verlaengert, das Ergebnis waere der
@ -320,15 +325,20 @@ class UserUtil
->orderBy('id')
->chunkById(200, function ($users) use (&$set): void {
foreach ($users as $user) {
$paidAt = self::firstRegistrationPaidAt((int) $user->id)
?? ($user->active_date ? Carbon::parse($user->active_date) : null);
$paidAt = self::firstRegistrationPaidAt((int) $user->id);
if ($paidAt === null && ! self::hasRegistrationOrder((int) $user->id)) {
$paidAt = $user->active_date ? Carbon::parse($user->active_date) : null;
}
if ($paidAt === null) {
continue;
}
$user->partner_paid_at = $paidAt;
$user->timestamps = false;
$user->saveQuietly();
$user->timestamps = true;
$set++;
}
});
@ -336,6 +346,14 @@ class UserUtil
return $set;
}
private static function hasRegistrationOrder(int $userId): bool
{
return ShoppingOrder::query()
->where('auth_user_id', $userId)
->where('payment_for', 1)
->exists();
}
private static function firstRegistrationPaidAt(int $userId): ?Carbon
{
$order = ShoppingOrder::query()