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