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

@ -183,6 +183,7 @@ it('protokolliert die Kuendigung in der Status-Historie', function (): void {
'view' => 'portal',
'abo_interval' => $abo->abo_interval,
'abo_cancel' => 'true',
'abo_cancel_confirm' => '1',
]);
expect((int) $abo->fresh()->status)->toBe(4);

View file

@ -165,7 +165,7 @@ it('haelt bei der Kuendigung fest, wer gekuendigt hat', function () {
$repository->setModel($abo);
$repository->update([
'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me',
'abo_interval' => 5, 'abo_cancel' => 'true',
'abo_interval' => 5, 'abo_cancel' => 'true', 'abo_cancel_confirm' => '1',
]);
$abo = $abo->fresh();
@ -244,6 +244,75 @@ it('rendert das Partial mit Badge, Zustand und Erklaerung', function () {
->toContain(__('abo.state_text_payment_failed', ['date' => '10.09.2026']));
});
it('zieht das Pausenende mit, wenn der Liefertermin waehrend der Pause geaendert wird (Audit F3)', function () {
$user = aseUser();
$abo = aseAbo($user);
$this->actingAs($user);
$repository = new AboRepository;
$repository->setModel($abo);
$repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 5, 'abo_pause' => 'true']);
expect($abo->fresh()->paused_until->format('Y-m-d'))->toBe('2026-11-05');
// Liefertag im Pausenmonat nach hinten: 5.11. -> 20.11.
$repository->setModel($abo->fresh());
expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 20, 'abo_is_active' => 1]))->toBeTrue();
$abo = $abo->fresh();
expect($abo->getRawOriginal('next_date'))->toBe('2026-11-20')
->and($abo->paused_until->format('Y-m-d'))->toBe('2026-11-20')
->and($abo->status_reason)->toBe('paused_by_consultant')
->and(aseExplain($abo)->text)->toContain('20.11.2026');
// Admin-Termin: gleiche Regel.
$repository->setModel($abo);
expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'admin', 'abo_interval' => 10, 'abo_next_month' => '2026-12', 'abo_is_active' => 'true']))->toBeTrue()
->and($abo->fresh()->paused_until->format('Y-m-d'))->toBe('2026-12-10');
// Ausfuehrung gelaufen: Pausenende UND Pausengrund weg.
$abo->fresh()->update(['last_date' => '2026-12-10', 'next_date' => '2027-01-10']);
expect($abo->fresh()->paused_until)->toBeNull()
->and($abo->fresh()->status_reason)->toBeNull()
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
// Ohne Pause bleibt paused_until bei einer Terminaenderung leer.
$plain = aseAbo(aseUser(), ['next_date' => '2026-10-05']);
$repository->setModel($plain);
$repository->update(['action' => 'abo_update_settings', 'id' => $plain->id, 'view' => 'me', 'abo_interval' => 20, 'abo_is_active' => 1]);
expect($plain->fresh()->paused_until)->toBeNull();
});
it('kuendigt im Selfservice nur mit bestaetigten Folgen (Audit)', function () {
$user = aseUser();
$abo = aseAbo($user);
$this->actingAs($user);
$repository = new AboRepository;
$repository->setModel($abo);
expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 5, 'abo_cancel' => 'true']))->toBeFalse()
->and(session('alert-error'))->toBe(__('abo.cancel_confirm_required'))
->and((int) $abo->fresh()->status)->toBe(2);
expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 5, 'abo_cancel' => 'true', 'abo_cancel_confirm' => '0']))->toBeFalse()
->and((int) $abo->fresh()->status)->toBe(2);
expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 5, 'abo_cancel' => 'true', 'abo_cancel_confirm' => '1']))->toBeTrue()
->and((int) $abo->fresh()->status)->toBe(4);
});
it('laesst der Backfill updated_at unangetastet (Audit F5)', function () {
$abo = aseAbo(aseUser(), ['status' => 3]);
UserAbo::whereKey($abo->id)->toBase()->update(['status_reason' => null, 'updated_at' => '2026-08-01 12:00:00']);
$this->artisan('abo:backfill-status-reasons')->assertSuccessful();
expect($abo->fresh()->status_reason)->toBe('payment_failed')
->and($abo->fresh()->updated_at->format('Y-m-d H:i:s'))->toBe('2026-08-01 12:00:00');
});
it('traegt Grund und Pausenende fuer Bestandsdaten nach', function () {
$failed = aseAbo(aseUser(), ['status' => 3]);
$cancelled = aseAbo(aseUser(), ['status' => 4, 'active' => false]);

View file

@ -144,7 +144,7 @@ describe('A16: Statushistorie über alle Schreibpfade', function () {
$abo = makeStatusGuardAbo(['next_date' => '2026-04-20']);
$this->actingAs($abo->user);
statusGuardUpdate($abo, ['view' => 'me', 'abo_cancel' => 'true']);
statusGuardUpdate($abo, ['view' => 'me', 'abo_cancel' => 'true', 'abo_cancel_confirm' => '1']);
$entry = $abo->status_histories()->sole();

View file

@ -152,6 +152,7 @@ it('kündigt das eigene Abo nach erfüllter Mindestlaufzeit weiterhin regulär',
'view' => 'me',
'abo_interval' => 5,
'abo_cancel' => 'true',
'abo_cancel_confirm' => '1',
]);
expect((int) $abo->fresh()->status)->toBe(4)

View file

@ -149,6 +149,55 @@ it('haelt Kachel und verlinkte Liste der neuen Abos zusammen', function () {
->and($details['summary']['abo_count'])->toBe(1);
});
it('haelt Neupartner-Kachel und verlinkte Liste zusammen, auch nach spaeter nachgetragenem Zahlungsdatum (Audit F2)', function () {
$root = freezeUser();
// Registriert und bezahlt im August: Neupartner August.
$anna = User::forceCreate([
'email' => 'freeze-anna-'.uniqid('', true).'@example.test', 'password' => bcrypt('secret'), 'lang' => 'de',
'm_level' => 1, 'm_sponsor' => $root->id, 'active_date' => '2026-08-28', 'partner_paid_at' => '2026-08-28', 'payment_account' => '2027-08-28',
]);
app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
// Nachtraeglich korrigiert: Zahlung war erst im September.
User::whereKey($anna->id)->toBase()->update(['partner_paid_at' => '2026-09-01 06:49:00']);
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 8, 2026);
expect((int) $overview['totals']['new_partners'])->toBe(1)
->and($details['rows'])->toHaveCount(1)
->and($details['rows'][0]['user_id'])->toBe($anna->id);
});
it('rechnet fuer Snapshots ohne Neupartner-IDs mit der damaligen Regel (Registrierungsmonat)', function () {
$root = freezeUser();
// Vor dem 16.09. gezaehlt: registriert im August, bezahlt erst im September.
$anna = User::forceCreate([
'email' => 'freeze-anna-'.uniqid('', true).'@example.test', 'password' => bcrypt('secret'), 'lang' => 'de',
'm_level' => 1, 'm_sponsor' => $root->id, 'active_date' => '2026-08-28', 'partner_paid_at' => '2026-09-01 06:49:00', 'payment_account' => '2027-09-01',
]);
$snapshot = app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026);
// Alten Snapshot nachstellen: Kachel nach alter Regel = 1, keine gespeicherten IDs.
$payload = $snapshot->payload;
foreach ($payload['lines'] as &$line) {
unset($line['new_partner_ids']);
$line['new_partners'] = 1;
}
unset($line);
$payload['totals']['new_partners'] = 1;
$snapshot->update(['payload' => $payload]);
$overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 8, 2026);
expect((int) $overview['totals']['new_partners'])->toBe(1)
->and($details['rows'])->toHaveCount(1)
->and($details['rows'][0]['user_id'])->toBe($anna->id);
});
it('rechnet den laufenden Monat weiterhin live', function () {
$root = freezeUser();
$anna = freezeUser($root->id);

View file

@ -106,7 +106,55 @@ describe('partner_paid_at: erste Kontozahlung', function () {
});
});
describe('partner_paid_at: Rechnungskauf (Audit 18.09., F1)', function () {
it('setzt den Wert erst bei der Zahlung der Rechnung, nicht bei der offenen Rechnung', function () {
Carbon::setTestNow(Carbon::parse('2026-08-31 18:00:00'));
$user = ppaMakeUser();
$product = Product::forceCreate([
'name' => 'Starterpaket', 'title' => 'Starterpaket', 'price' => 89.0, 'tax' => 19.0, 'active' => true,
'pos' => 1, 'weight' => 0, 'points' => 0, 'slug' => 'starter-'.uniqid(), 'action' => [0],
]);
$order = ppaRegistrationOrder($user, false);
ShoppingOrderItem::forceCreate(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1, 'price' => 89.0]);
// Rechnungskauf: Konto wird vorab freigeschaltet, bezahlt ist noch nichts.
Payment::paymentStatusPaidAction($order->fresh(), false);
$user->refresh();
expect($user->payment_account)->not->toBeNull()
->and($user->partner_paid_at)->toBeNull()
->and($user->isNewPartnerIn(8, 2026))->toBeFalse();
// Zahlungseingang im September: jetzt zaehlt er, und zwar im September.
Carbon::setTestNow(Carbon::parse('2026-09-01 09:00:00'));
Payment::paymentStatusPaidAction($order->fresh(), true);
$user->refresh();
expect($user->partner_paid_at?->toDateString())->toBe('2026-09-01')
->and($user->isNewPartnerIn(9, 2026))->toBeTrue()
->and($user->isNewPartnerIn(8, 2026))->toBeFalse();
});
});
describe('partner_paid_at: Rueckfuellung fuer bestehende Partner', function () {
it('laesst eine offene Rechnung ohne bezahlte Bestellung leer statt das Aktivierungsdatum zu nehmen (Audit F1)', function () {
$user = ppaMakeUser(['payment_account' => '2027-08-28 00:00:00']);
ppaRegistrationOrder($user, false);
expect(UserUtil::backfillPartnerPaidAt())->toBe(0)
->and($user->fresh()->partner_paid_at)->toBeNull();
});
it('laesst updated_at beim Rueckfuellen unangetastet (Audit F5)', function () {
$user = ppaMakeUser(['payment_account' => '2027-08-28 00:00:00']);
User::whereKey($user->id)->toBase()->update(['updated_at' => '2026-05-05 05:05:05']);
UserUtil::backfillPartnerPaidAt();
expect($user->fresh()->partner_paid_at)->not->toBeNull()
->and($user->fresh()->updated_at->format('Y-m-d H:i:s'))->toBe('2026-05-05 05:05:05');
});
it('nimmt die Punktebuchung der ersten bezahlten Registrierungsbestellung', function () {
$user = ppaMakeUser(['payment_account' => '2027-09-01 06:49:35']);
$order = ppaRegistrationOrder($user);