mivita/tests/Feature/AboStatusExplainTest.php
Kevin 03416b0afe 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>
2026-09-18 08:59:45 +00:00

341 lines
15 KiB
PHP

<?php
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAboStatusHistory;
use App\Repositories\AboRepository;
use App\Services\Abo\AboStatusExplainer;
use App\Services\Abo\AboStatusInfo;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Ticket A3 (Entwicklungsplan September 2026): Status und Grund eines Abos
* sichtbar machen — Grund am Model (`status_reason`, `paused_until`),
* Fehlertext an der Abo-Bestellung, Erklaerung ueber den Explainer, Anzeige
* ueber das Partial und den Backfill fuer Bestandsdaten.
*/
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow('2026-09-16 10:00:00');
app('request')->setLaravelSession(app('session.store'));
});
afterEach(fn () => Carbon::setTestNow());
function aseUser(): User
{
return User::forceCreate([
'email' => 'ase-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'payment_account' => now()->addYear(),
]);
}
function aseAbo(User $user, array $attributes = [], int $paidDeliveries = 6): UserAbo
{
$abo = UserAbo::create(array_merge([
'user_id' => $user->id,
'member_id' => $user->id,
'shopping_user_id' => 0,
'email' => 'ase-abo-'.uniqid('', true).'@example.com',
'is_for' => 'me',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => 2,
'abo_interval' => 5,
'start_date' => '2026-01-05',
'last_date' => '2026-09-05',
'next_date' => '2026-10-05',
], $attributes));
for ($i = 0; $i < $paidDeliveries; $i++) {
UserAboOrder::create(['user_abo_id' => $abo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]);
}
return $abo->fresh();
}
function aseExplain(UserAbo $abo): AboStatusInfo
{
return app(AboStatusExplainer::class)->explain($abo->fresh());
}
it('erklaert ein planmaessig laufendes Abo mit letzter und naechster Lieferung', function () {
$info = aseExplain(aseAbo(aseUser()));
expect($info->state)->toBe(AboStatusInfo::STATE_ACTIVE)
->and($info->color)->toBe('success')
->and($info->text)->toBe(__('abo.state_text_active', ['last' => '05.09.2026', 'next' => '05.10.2026']));
});
it('macht die Pause sichtbar: bis wann, durch wen, seit wann', 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' => 'portal',
'abo_interval' => 5, 'abo_pause' => 'true',
]))->toBeTrue();
$abo = $abo->fresh();
expect($abo->status_reason)->toBe('paused_by_customer')
->and($abo->paused_until->format('Y-m-d'))->toBe('2026-11-05')
->and($abo->isPaused())->toBeTrue()
->and(UserAbo::paused()->pluck('id')->all())->toBe([$abo->id]);
$info = aseExplain($abo);
expect($info->state)->toBe(AboStatusInfo::STATE_PAUSED)
->and($info->actor)->toBe('customer')
->and($info->text)->toBe(__('abo.state_text_paused', ['until' => '05.11.2026', 'actor' => __('abo.actor_by_customer'), 'date' => '16.09.2026']));
// Ausfuehrung gelaufen (last_date bewegt sich): Pause ist vorbei.
$abo->update(['last_date' => '2026-11-05', 'next_date' => '2026-12-05']);
expect($abo->fresh()->paused_until)->toBeNull()
->and($abo->fresh()->isPaused())->toBeFalse()
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
});
it('leitet die Pause fuer Bestandsdaten ohne paused_until aus den Pausenfeldern ab', function () {
$abo = aseAbo(aseUser(), ['pause_count' => 1, 'last_pause_date' => '2026-09-10', 'last_date' => '2026-09-05']);
expect($abo->pausedUntil()?->format('Y-m-d'))->toBe('2026-10-05')
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_PAUSED);
$delivered = aseAbo(aseUser(), ['pause_count' => 1, 'last_pause_date' => '2026-08-10', 'last_date' => '2026-09-05']);
expect($delivered->pausedUntil())->toBeNull()
->and(aseExplain($delivered)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
});
it('setzt bei Zahlungsfehler den Grund automatisch und zeigt den Fehlertext der Bestellung', function () {
$abo = aseAbo(aseUser());
$abo->update(['status' => 3, 'last_date' => '2026-09-10']);
UserAboOrder::create([
'user_abo_id' => $abo->id, 'shopping_order_id' => 0, 'status' => 3, 'paid' => false,
'error_message' => AboStatusExplainer::paymentErrorMessage(['errorcode' => '905', 'errormessage' => 'Karte abgelaufen', 'customermessage' => 'Ihre Karte ist abgelaufen']),
]);
expect($abo->fresh()->status_reason)->toBe('payment_failed')
->and(UserAbo::paymentFailed()->pluck('id')->all())->toBe([$abo->id]);
$info = aseExplain($abo);
expect($info->state)->toBe(AboStatusInfo::STATE_PAYMENT_FAILED)
->and($info->color)->toBe('danger')
->and($info->errorMessage)->toBe('[905] Ihre Karte ist abgelaufen')
->and($info->text)->toContain(__('abo.state_text_payment_failed', ['date' => '10.09.2026']))
->and($info->text)->toContain('[905] Ihre Karte ist abgelaufen');
// Zahlung nachgeholt: Grund verschwindet mit dem Statuswechsel.
$abo->update(['status' => 2, 'active' => true, 'last_date' => '2026-09-12']);
expect($abo->fresh()->status_reason)->toBeNull()
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
});
it('liefert keinen Fehlertext aus einer leeren oder unlesbaren Zahlungsantwort', function () {
expect(AboStatusExplainer::paymentErrorMessage(null))->toBeNull()
->and(AboStatusExplainer::paymentErrorMessage(['status' => 'ERROR']))->toBeNull()
->and(AboStatusExplainer::paymentErrorMessage(['errormessage' => ' ']))->toBeNull()
->and(AboStatusExplainer::paymentErrorMessage(['errormessage' => 'Deckung nicht ausreichend']))->toBe('Deckung nicht ausreichend')
->and(mb_strlen(AboStatusExplainer::paymentErrorMessage(['errormessage' => str_repeat('x', 400)])))->toBe(255);
});
it('haelt bei der Kuendigung fest, wer gekuendigt hat', 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_cancel' => 'true', 'abo_cancel_confirm' => '1',
]);
$abo = $abo->fresh();
expect((int) $abo->status)->toBe(4)
->and($abo->status_reason)->toBe('cancelled_by_consultant');
$info = aseExplain($abo);
expect($info->state)->toBe(AboStatusInfo::STATE_CANCELLED)
->and($info->actor)->toBe('consultant')
->and($info->text)->toBe(__('abo.state_text_cancelled', ['date' => '16.09.2026', 'actor' => __('abo.actor_by_consultant')]));
});
it('haelt bei der Admin-Deaktivierung den Akteur fest und loescht ihn bei der Reaktivierung', function () {
$admin = aseUser();
$abo = aseAbo($admin);
$this->actingAs($admin);
$repository = new AboRepository;
$repository->setModel($abo);
$repository->update([
'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'admin',
'abo_interval' => 5, 'abo_next_month' => '2026-10', 'abo_is_active' => '',
]);
$abo = $abo->fresh();
expect((int) $abo->status)->toBe(6)
->and($abo->status_reason)->toBe('deactivated_by_admin');
$info = aseExplain($abo);
expect($info->state)->toBe(AboStatusInfo::STATE_DEACTIVATED)
->and($info->text)->toBe(__('abo.state_text_deactivated', ['date' => '16.09.2026', 'actor' => __('abo.actor_by_admin')]));
$repository->setModel($abo);
$repository->update([
'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'admin',
'abo_interval' => 5, 'abo_next_month' => '2026-10', 'abo_is_active' => 'true',
]);
expect($abo->fresh()->status_reason)->toBeNull()
->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_ACTIVE);
});
it('nutzt fuer Bestandsdaten ohne Grund das Statusprotokoll', function () {
$abo = aseAbo(aseUser(), ['status' => 4, 'active' => false, 'cancel_date' => '2026-08-01']);
// Der Grund entstand automatisch beim Anlegen (Status 4 => system); alte Daten haben keinen.
UserAbo::whereKey($abo->id)->update(['status_reason' => null]);
UserAboStatusHistory::create(['user_abo_id' => $abo->id, 'action' => UserAboStatusHistory::ACTION_CANCELLED, 'view' => 'portal']);
$info = aseExplain($abo);
expect($info->actor)->toBe('customer')
->and($info->text)->toContain('01.08.2026')
->and($info->text)->toContain(__('abo.actor_by_customer'));
});
it('erklaert ein Abo, das auf die erste Zahlung wartet', function () {
$info = aseExplain(aseAbo(aseUser(), ['status' => 1], 0));
expect($info->state)->toBe(AboStatusInfo::STATE_PENDING_PAYMENT)
->and($info->label)->toBe(__('abo.state_pending_payment'));
});
it('rendert das Partial mit Badge, Zustand und Erklaerung', function () {
$abo = aseAbo(aseUser(), ['status' => 3, 'last_date' => '2026-09-10']);
$html = view('admin.abo._status_explain', ['abo' => $abo, 'compact' => true])->render();
expect($html)
->toContain('data-state="payment_failed"')
->toContain('badge-danger')
->toContain(__('abo.state_payment_failed'))
->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]);
$paused = aseAbo(aseUser(), ['pause_count' => 1, 'last_pause_date' => '2026-09-10', 'last_date' => '2026-09-05']);
$delivered = aseAbo(aseUser(), ['pause_count' => 1, 'last_pause_date' => '2026-08-10', 'last_date' => '2026-09-05']);
$running = aseAbo(aseUser());
UserAbo::whereIn('id', [$failed->id, $cancelled->id, $paused->id])->update(['status_reason' => null, 'paused_until' => null]);
UserAboStatusHistory::create(['user_abo_id' => $cancelled->id, 'action' => UserAboStatusHistory::ACTION_CANCELLED, 'view' => 'ot']);
UserAboStatusHistory::create(['user_abo_id' => $paused->id, 'action' => UserAboStatusHistory::ACTION_PAUSED, 'view' => 'admin']);
$this->artisan('abo:backfill-status-reasons', ['--dry-run' => true])
->expectsOutputToContain('DRY-RUN')
->assertSuccessful();
expect($failed->fresh()->status_reason)->toBeNull();
$this->artisan('abo:backfill-status-reasons')->assertSuccessful();
expect($failed->fresh()->status_reason)->toBe('payment_failed')
->and($cancelled->fresh()->status_reason)->toBe('cancelled_by_consultant')
->and($paused->fresh()->status_reason)->toBe('paused_by_admin')
->and($paused->fresh()->paused_until->format('Y-m-d'))->toBe('2026-10-05')
->and($delivered->fresh()->paused_until)->toBeNull()
->and($running->fresh()->status_reason)->toBeNull();
});